From 1dcb46b2f8eeea58fa57aecc45fafd5a0d1a8318 Mon Sep 17 00:00:00 2001 From: one-bit <22821064+one-bit@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:11:30 +0000 Subject: [PATCH] Unreal Engine installations and their bundled Clang are found on Linux (#191) Reads Linux registrations from ~/.config/Epic/UnrealEngine/Install.ini, honours a valid UE_ROOT, takes the version from Build.version, and counts the selected engine's own bundled Clang as its C++ tools. --- crates/engines/src/game.rs | 162 ++++++++++++++++++++++++++++++++++--- docs/ROADMAP.md | 5 ++ docs/STATUS.md | 15 ++++ 3 files changed, 170 insertions(+), 12 deletions(-) diff --git a/crates/engines/src/game.rs b/crates/engines/src/game.rs index 213ccb9b..25d76ccb 100644 --- a/crates/engines/src/game.rs +++ b/crates/engines/src/game.rs @@ -120,6 +120,7 @@ impl Builder { pub fn needs(self) -> &'static str { match self { Builder::Unity => "About 10 GB. Free for personal use with a Unity account.", + Builder::Unreal if cfg!(target_os = "linux") => "An Unreal Engine Linux installation with its bundled Clang C++ toolchain.", Builder::Unreal => "About 60 GB, an Epic Games account, and Visual Studio's C++ tools.", Builder::Godot => "A small download (under 200 MB). Free, no account needed.", Builder::Rust => "The Rust toolchain (rustup.rs).", @@ -518,7 +519,7 @@ pub struct Inventory { pub engines: Vec, pub unity_hub: Option, pub epic_launcher: Option, - /// A C++ compiler Unreal can use (Visual Studio's C++ tools, or Xcode on a Mac). + /// A C++ compiler Unreal can use (Visual Studio, Xcode, or the selected Linux engine's bundled Clang). pub cpp_tools: bool, } @@ -541,6 +542,8 @@ pub struct Places { pub program_data: Option, pub app_data: Option, pub home: Option, + /// An explicit Unreal installation root, preferred over registered installations when valid. + pub unreal_root: Option, pub applications: Option, /// Craft Anything's own engine store (where it puts Godot). pub store: PathBuf, @@ -549,6 +552,7 @@ pub struct Places { } impl Places { + /// Capture environment-based search locations and the supplied engine store, with PATH lookup enabled. pub fn here(store: PathBuf) -> Places { let env = |k: &str| std::env::var_os(k).map(PathBuf::from); Places { @@ -556,6 +560,7 @@ impl Places { program_data: env("ProgramData"), app_data: env("APPDATA"), home: env("HOME").or_else(|| env("USERPROFILE")), + unreal_root: env("UE_ROOT"), applications: cfg!(target_os = "macos").then(|| PathBuf::from("/Applications")), store, use_path: true, @@ -574,6 +579,7 @@ pub fn inventory(p: &Places) -> Inventory { } engines.sort_by(|a, b| (a.builder as u8).cmp(&(b.builder as u8)).then_with(|| version_key(&b.version).cmp(&version_key(&a.version)))); engines.dedup_by(|a, b| a.program == b.program); + let cpp_tools = cpp_tools(p, engines.iter().find(|i| i.builder == Builder::Unreal)); let first = |paths: Vec| paths.into_iter().find(|x| x.exists()); Inventory { engines, @@ -587,7 +593,7 @@ pub fn inventory(p: &Places) -> Inventory { .chain(p.applications.iter().map(|a| a.join("Epic Games Launcher.app"))) .collect(), ), - cpp_tools: cpp_tools(p), + cpp_tools, } } @@ -623,8 +629,16 @@ fn unity_editors(p: &Places) -> Vec { .collect() } +/// Prefer a valid absolute `UE_ROOT`; otherwise discover installs in registries and standard folders. fn unreal_installs(p: &Places) -> Vec { + if let Some(install) = p.unreal_root.as_deref().filter(|root| root.is_absolute()).and_then(unreal_install) { + return vec![install]; + } let mut roots: Vec = Vec::new(); + // Match UnrealVersionSelector's fixed per-user registry location. + if let Some(home) = &p.home { + roots.extend(unreal_registry_roots(&home.join(".config"))); + } // The Epic Games Launcher lists its engine installs here. if let Some(dat) = p.program_data.as_ref().and_then(|d| std::fs::read_to_string(d.join("Epic/UnrealEngineLauncher/LauncherInstalled.dat")).ok()) && let Ok(v) = serde_json::from_str::(&dat) @@ -637,20 +651,50 @@ fn unreal_installs(p: &Places) -> Vec { } let shared = [p.program_files.iter().map(|pf| pf.join("Epic Games")).collect::>(), vec![PathBuf::from("/Users/Shared/Epic Games")]].concat(); roots.extend(shared.iter().flat_map(|d| subdirs(d)).filter(|d| d.file_name().is_some_and(|n| n.to_string_lossy().starts_with("UE_")))); + roots.sort(); + roots.dedup(); + roots.iter().filter_map(|dir| unreal_install(dir)).collect() +} + +/// UnrealVersionSelector's per-user Linux registry. Only the Installations section contains roots. +fn unreal_registry_roots(config: &Path) -> Vec { + let Some(bytes) = head(&config.join("Epic/UnrealEngine/Install.ini"), 1 << 20) else { return Vec::new() }; + let Ok(text) = std::str::from_utf8(&bytes) else { return Vec::new() }; + let mut installations = false; + let mut roots = Vec::new(); + for line in text.trim_start_matches('\u{feff}').lines().map(str::trim) { + if line.starts_with('[') { + installations = line == "[Installations]"; + } else if installations + && !line.starts_with([';', '#']) + && let Some((_, root)) = line.split_once('=') + { + let root = PathBuf::from(root.trim().trim_matches('"')); + if root.is_absolute() { + roots.push(root); + } + } + } + roots +} + +/// Find an editor in an install root, using Linux's `Build.version` when valid or the directory name otherwise. +fn unreal_install(dir: &Path) -> Option { let exes = [ "Engine/Binaries/Win64/UnrealEditor.exe", "Engine/Binaries/Win64/UE4Editor.exe", "Engine/Binaries/Mac/UnrealEditor.app/Contents/MacOS/UnrealEditor", "Engine/Binaries/Linux/UnrealEditor", ]; - roots - .iter() - .filter_map(|dir| { - let program = exes.iter().map(|e| dir.join(e)).find(|e| e.is_file())?; - let version = dir.file_name()?.to_string_lossy().trim_start_matches("UE_").to_string(); - Some(Installed { builder: Builder::Unreal, version, program }) - }) - .collect() + let program = exes.iter().map(|e| dir.join(e)).find(|e| e.is_file())?; + let version = program + .ends_with("Engine/Binaries/Linux/UnrealEditor") + .then(|| head(&dir.join("Engine/Build/Build.version"), 64 << 10)) + .flatten() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|v| Some(format!("{}.{}.{}", v.get("MajorVersion")?.as_u64()?, v.get("MinorVersion")?.as_u64()?, v.get("PatchVersion")?.as_u64()?))) + .or_else(|| dir.file_name().map(|n| n.to_string_lossy().trim_start_matches("UE_").to_string()))?; + Some(Installed { builder: Builder::Unreal, version, program }) } fn godot_installs(p: &Places) -> Vec { @@ -683,12 +727,20 @@ fn godot_installs(p: &Places) -> Vec { v } -fn cpp_tools(p: &Places) -> bool { +/// Check filesystem presence of MSVC, Xcode, or the selected Linux engine's bundled Clang. +/// This readiness heuristic does not execute or validate a compiler. +fn cpp_tools(p: &Places, unreal: Option<&Installed>) -> bool { if p.applications.as_ref().is_some_and(|a| a.join("Xcode.app").exists()) { return true; } // Visual Studio //VC/Tools/MSVC, in either Program Files. - p.program_files.iter().flat_map(|pf| subdirs(&pf.join("Microsoft Visual Studio"))).flat_map(|y| subdirs(&y)).any(|e| e.join("VC/Tools/MSVC").is_dir()) + if p.program_files.iter().flat_map(|pf| subdirs(&pf.join("Microsoft Visual Studio"))).flat_map(|y| subdirs(&y)).any(|e| e.join("VC/Tools/MSVC").is_dir()) { + return true; + } + // A system clang can have the wrong version or sysroot. Look inside the selected Linux engine. + let Some(unreal) = unreal.filter(|i| i.program.ends_with("Engine/Binaries/Linux/UnrealEditor")) else { return false }; + let Some(root) = unreal.program.ancestors().nth(4) else { return false }; + subdirs(&root.join("Engine/Extras/ThirdPartyNotUE/SDKs/HostLinux/Linux_x64")).iter().any(|sdk| sdk.join("x86_64-unknown-linux-gnu/bin/clang++").is_file()) } // ---- getting one --------------------------------------------------------------------------------- @@ -1003,6 +1055,92 @@ mod tests { assert!(!inv.ready(Builder::Rust), "PATH isn't searched here"); } + #[test] + fn linux_unreal_registry_finds_an_install_outside_the_launcher_folders() { + let d = tempfile::tempdir().unwrap(); + let home = d.path().join("home"); + let root = d.path().join("custom engine = 5"); + touch(&root, "Engine/Binaries/Linux/UnrealEditor", b""); + touch(&root, "Engine/Build/Build.version", br#"{"MajorVersion":5,"MinorVersion":8,"PatchVersion":4}"#); + let ignored = d.path().join("ignored-engine"); + touch(&ignored, "Engine/Binaries/Linux/UnrealEditor", b""); + touch( + &home, + ".config/Epic/UnrealEngine/Install.ini", + format!( + "\u{feff}[Installations]\r\n; Registered by UnrealVersionSelector\r\nengine={}\r\nstale={}\r\nrelative=relative/engine\r\nempty=\r\n[Other]\r\nignored={}\r\n", + root.display(), d.path().join("missing-engine").display(), ignored.display() + ) + .as_bytes(), + ); + + let inv = inventory(&Places { home: Some(home), ..Default::default() }); + let installed = inv.best(Builder::Unreal).expect("the registered Linux editor must be found"); + assert_eq!(installed.program, root.join("Engine/Binaries/Linux/UnrealEditor")); + assert_eq!(installed.version, "5.8.4", "the engine build file supplies the version, not its directory name"); + assert_eq!(inv.engines.iter().filter(|i| i.builder == Builder::Unreal).count(), 1); + assert!(!inv.ready(Builder::Unreal), "an editor alone does not prove its C++ toolchain is installed"); + } + + #[test] + fn linux_unreal_is_ready_with_its_bundled_clang() { + let d = tempfile::tempdir().unwrap(); + let home = d.path().join("home"); + let root = d.path().join("UE_5.8"); + touch(&root, "Engine/Binaries/Linux/UnrealEditor", b""); + touch(&home, ".config/Epic/UnrealEngine/Install.ini", format!("[Installations]\nengine={}\n", root.display()).as_bytes()); + // An unrelated compiler is not evidence that Unreal's own toolchain is present. + touch(&home, "bin/clang++", b""); + let places = Places { home: Some(home), ..Default::default() }; + assert!(!inventory(&places).ready(Builder::Unreal)); + + touch(&root, "Engine/Extras/ThirdPartyNotUE/SDKs/HostLinux/Linux_x64/v26_clang-20.1.8-rockylinux8/x86_64-unknown-linux-gnu/bin/clang++", b""); + let inv = inventory(&places); + assert!(inv.cpp_tools, "the compiler shipped with this Unreal install must be recognized"); + assert!(inv.ready(Builder::Unreal)); + } + + #[test] + fn linux_unreal_explicit_root_selects_that_install() { + let d = tempfile::tempdir().unwrap(); + let home = d.path().join("home"); + let root = d.path().join("UE_5.8"); + let other = d.path().join("UE_6.0"); + touch(&root, "Engine/Binaries/Linux/UnrealEditor", b""); + touch(&other, "Engine/Binaries/Linux/UnrealEditor", b""); + touch(&home, ".config/Epic/UnrealEngine/Install.ini", format!("[Installations]\nengine={}\n", other.display()).as_bytes()); + let mut places = Places { home: Some(home), unreal_root: Some(root.clone()), ..Default::default() }; + + let inv = inventory(&places); + assert_eq!(inv.best(Builder::Unreal).unwrap().program, root.join("Engine/Binaries/Linux/UnrealEditor")); + assert_eq!(inv.engines.iter().filter(|i| i.builder == Builder::Unreal).count(), 1); + places.unreal_root = Some(d.path().join("missing")); + assert_eq!(inventory(&places).best(Builder::Unreal).unwrap().program, other.join("Engine/Binaries/Linux/UnrealEditor")); + } + + #[test] + fn linux_unreal_does_not_borrow_another_installs_compiler() { + let d = tempfile::tempdir().unwrap(); + let home = d.path().join("home"); + let older = d.path().join("UE_5.7"); + let newer = d.path().join("UE_5.8"); + for root in [&older, &newer] { + touch(root, "Engine/Binaries/Linux/UnrealEditor", b""); + } + touch( + &home, + ".config/Epic/UnrealEngine/Install.ini", + format!("[Installations]\nolder={}\nnewer={}\nduplicate={}\n", older.display(), newer.display(), older.display()).as_bytes(), + ); + touch(&older, "Engine/Extras/ThirdPartyNotUE/SDKs/HostLinux/Linux_x64/v25_clang-18.1.0-rockylinux8/x86_64-unknown-linux-gnu/bin/clang++", b""); + + let inv = inventory(&Places { home: Some(home), ..Default::default() }); + assert_eq!(inv.best(Builder::Unreal).unwrap().program, newer.join("Engine/Binaries/Linux/UnrealEditor")); + assert_eq!(inv.engines.iter().filter(|i| i.builder == Builder::Unreal).count(), 2); + assert!(!inv.cpp_tools, "an older engine's compiler cannot establish readiness for the selected engine"); + assert!(!inv.ready(Builder::Unreal)); + } + #[test] fn unpacked_macos_godot_is_found_in_the_store() { let d = tempfile::tempdir().unwrap(); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5df94c08..09345d8d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -390,6 +390,11 @@ Pending changes: - [x] Engines and settings as tools (`Setup` toolset): `engines_list` (external engines, Ghidra, game engines and readiness), `engines_install` (Ghidra, Godot, pinned engines, with progress; Unity/Unreal get their download page), `settings_show`, `settings_set` +- [x] Find registered Linux Unreal installations and their bundled Clang toolchain; support an + explicit `UE_ROOT` ([PR #191](https://github.com/storytold/craft-anything/pull/191)) + — claimed: one-bit, branch `bugfix/r1.6-linux-unreal-detection` +- [ ] Give Linux-specific Clang guidance in Unreal's guided installer when the bundled compiler + is missing (the requirements summary is already platform-specific). - [x] The app's Godot, Unity/Unreal installer and Ghidra flows, and its play, preview and open-folder buttons, call the tools (`CraftApp::act`); inventory reads stay in the UI - [x] Launch, preview and open-folder as tools: `project_play` (needs consent: it runs the diff --git a/docs/STATUS.md b/docs/STATUS.md index f66093a9..a8a2c49c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -26,6 +26,21 @@ workspace clippy, layers, fmt and the secret scan of all history were clean. Chi | Desktop app | Simple mode: sidebar with New project and the project history (kept across restarts); New project either makes something or just looks inside an app or file, a website or a game's files; a Game files screen (a game's archives; pictures from Unreal paks, Unity .assets files and Unity bundles saved as PNG after the person confirms they may unpack the game; uncompressed textures and the block formats DXT1, DXT5 and BC4–BC7), make prompt with picture attachments and, for anything recreated, a Clean room / Deep choice (also in the title bar; in Clean room, Just look inside, Game files, opening a file and the workbench ask for Deep mode instead), project screen (steps with the time spent and the AI cost and tokens of every build, saved in the project's usage.json by the run (a coding agent's figure is shown as an estimate at API prices, not the bill), what the build is on now and its activity as it arrives (kept in `build-activity.log`), pinned builder checklist, feed, question and approval cards, a Continue card when a build was paused or stopped part-way (the app or computer closed; the project shows Interrupted) that carries on the builder's own session instead of starting over, checks with screenshots and the requirements not met yet, the builder's open questions, preview, play, folder, follow-ups while it builds, which Claude Code takes at its next step; Up in the prompt box takes back the last note still waiting). Projects can start in a folder the person picks, whose files are either the app, edited in place, or material to start from (asked, or the answer remembered in Settings), and are listed from any number of folders (`project_folders`, `project_list`, `rebuild --project-folder`). Make says which AI builds ("Built by": automatic, or any AI that can build, by name); a pick stays with the project and builds its later changes. With nothing to build with, Start offers the coding agents found on the computer. Settings → AI turns an AI off or removes it. In the prompt box, Up at the very start and Down at the very end go through earlier prompts when no note is waiting. Questions about a build ("Just ask" under the prompt box, or a message starting with `/btw`, the first slash command; `rebuild_question_ask`) are answered in the conversation, where each folds away and is kept in the project, without a build or a round and changing nothing: Claude Code answers from a read-only copy of the builder's own session. Under each project's prompt box, "Builder's questions: Ask me / Decide yourself" chooses whether the builder's questions come to the person as cards or are decided by the builder, which says what it chose (`project_questions_set`; the default is in Settings and follows Auto mode until picked). Projects can be renamed, duplicated and deleted (right-click in the sidebar, or the project's page; a delete keeps the person's own files). A build can be pinned (Pin this build on its page, or `project_pin_*` from the CLI and MCP): a frozen copy in the project's pinned/ folder (the default) or a light one that shares dependencies and media with the project through hard links (each one's size shown first), opened from the sidebar's collapsible Pinned section (icons with a running dot when folded); a pin or Play runs once per folder unless another copy is asked for, and a start that quits at once says so with its last output. Typed drafts survive a restart. In a narrow window the sidebar folds to icons and the project's details move above its feed; messages float over the screen and errors stay until dismissed. Advanced mode: workbench with every panel wired to real tools, as a screen beside the projects (builds and their questions stay a click away) | UI unit and smoke tests (egui_kittest), offscreen snapshots of every screen | | CLI | Every tool, `--deep` or `--clean-room` for one run (never saved), `rebuild` (including `--continue-in`, and `--resume` to carry on a stopped build), `mcp --inbox`, `ai`, `setup`, `doctor`, `report` | CLI integration tests | +### Linux Unreal setup + +Unreal installations registered in `~/.config/Epic/UnrealEngine/Install.ini` are found on Linux. +A valid absolute `UE_ROOT` selects an installation explicitly; an invalid override falls back to +registered installations. The version comes from `Engine/Build/Build.version`, and C++ readiness +checks for the selected engine's bundled Clang, not an unrelated system compiler or another +installation's SDK. The Linux requirements summary (`Builder::needs` for Unreal) now describes +the bundled Clang toolchain. The guided installer still shows Visual Studio instructions when +Clang is missing; platform-specific guidance in that flow remains a follow-up. + +Checked with isolated registry and SDK fixtures and with a native Fedora install of Unreal +5.8.4. Readiness is a tool-presence check; a complete Unreal game build and SDK compatibility +validation have not been run for this change. Windows and macOS detection retain their existing +tests, without new live runs on those platforms. + ### Fixed during the audit Web tools couldn't launch a browser on Windows/macOS (Chrome/Edge are off PATH) or be pointed at