diff --git a/crates/benilla-app/examples/extended_launcher.rs b/crates/benilla-app/examples/extended_launcher.rs index 2964e55b..8c44196c 100644 --- a/crates/benilla-app/examples/extended_launcher.rs +++ b/crates/benilla-app/examples/extended_launcher.rs @@ -49,8 +49,8 @@ //! //! Its `Cargo.lock` starts as a copy of benilla's: cargo reads only the root's lockfile too, and //! without it resolves every crate to its newest compatible version, not the one benilla is built -//! with. A dev build looks for the `WoW` link and `benilla-config/` at benilla's repo root, which -//! for a git dependency is cargo's checkout, so `WOW_DATA` and `BENILLA_HOME` name them instead. +//! with. A dev build keeps its `WoW` link, `benilla-config/` and `.probe-identity` in its own +//! folder, beside that `Cargo.lock`, as benilla's does. use benilla_app::{AppExit, BuildId}; use bevy::prelude::*; diff --git a/crates/benilla-app/src/crash.rs b/crates/benilla-app/src/crash.rs index 6a237060..685439d8 100644 --- a/crates/benilla-app/src/crash.rs +++ b/crates/benilla-app/src/crash.rs @@ -140,6 +140,7 @@ mod tests { short: "0123456", date: "2026-09-16", profile: "debug", + project_dir: "", extended: false, }; diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 23155d3d..6ef385ba 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -237,12 +237,16 @@ fn launch(build: BuildId, extend: Option>) -> AppExit { ..build }; + // A dev build's install, state folder and probe identity resolve from the launcher's folder, + // so a crate on top of benilla keeps them in its own and not in cargo's checkout of benilla. + benilla_formats::set_project_folder(build.project_dir); + // From here a panic leaves `benilla-config/Diagnostics/crash-.txt`; armed before the // `App` exists, so a panic while plugins build is reported too. crash::install(build); let mut app = App::new(); - // The panel footer and the preflight banner read the stamp back. + // The panel footer and the build banner read the stamp back. app.insert_resource(build); // Static-scene transform tracking pinned on: the default threshold re-decides every frame with // two full scans of the rows the tracking exists to skip, and this scene is static-heavy. It diff --git a/crates/benilla-app/src/local_state.rs b/crates/benilla-app/src/local_state.rs index 96aef80d..6bfa0f4a 100644 --- a/crates/benilla-app/src/local_state.rs +++ b/crates/benilla-app/src/local_state.rs @@ -4,8 +4,8 @@ //! //! Resolution, in order, the same shape as [`benilla_formats::wow_data`]: //! 1. `$BENILLA_HOME`. -//! 2. `/benilla-config/`, dev builds only: a shipped binary must not carry the -//! build machine's source tree. +//! 2. `/benilla-config/`, dev builds only ([`benilla_formats::project_folder`], the +//! launcher's): a shipped binary must not carry the build machine's source tree. //! 3. `/benilla-config/`. //! //! With `$WOW_CAPTURE` set every path resolves to `None`: a capture neither reads nor writes @@ -39,11 +39,15 @@ pub(crate) fn home() -> Option { .map(|dir| dir.join(STATE_DIR)) } -/// The project folder a dev build keeps its state in: the primary checkout, whichever worktree -/// built the binary, so every worktree shares one settings folder. Anything unexpected falls back -/// to the crate root's grandparent rather than `None`. +/// The project folder a dev build keeps its state in ([`benilla_formats::project_folder`]), through +/// [`shared_root`]. fn dev_project_root() -> Option { - let here = crate::run_mode::dev_source_dir()?.ancestors().nth(2)?; + Some(shared_root(benilla_formats::project_folder()?)) +} + +/// `here`, or for a linked worktree its primary checkout, whichever worktree built the binary, so +/// every worktree shares one settings folder. Anything unexpected keeps `here`. +fn shared_root(here: &Path) -> PathBuf { let dot_git = here.join(".git"); // A linked worktree: `.git` is a file pointing at `/.git/worktrees/`. if dot_git.is_file() { @@ -59,12 +63,12 @@ fn dev_project_root() -> Option { .and_then(|d| d.parent()) { if primary.is_dir() { - return Some(primary.to_path_buf()); + return primary.to_path_buf(); } } } } - Some(here.to_path_buf()) + here.to_path_buf() } /// `benilla-config/config.toml`: the CVar overrides, the `Config.wtf` analog. @@ -402,8 +406,7 @@ mod tests { let h = home().expect("home() always resolves outside a capture"); assert!(h.ends_with(STATE_DIR), "{}", h.display()); - let Some(here) = crate::run_mode::dev_source_dir().and_then(|d| d.ancestors().nth(2)) - else { + let Some(here) = benilla_formats::project_folder() else { // Player build: beside the binary. let exe_dir = std::env::current_exe() .unwrap() @@ -430,6 +433,83 @@ mod tests { } } + /// A project folder that is a linked worktree keeps its state in the primary checkout, so every + /// worktree shares one; any other folder, a crate on top of benilla's included, keeps its own. + #[test] + fn the_state_root_is_the_folder_or_its_primary_checkout() { + let tmp = std::env::temp_dir().join(format!("benilla-root-{}", std::process::id())); + std::fs::remove_dir_all(&tmp).ok(); + let primary = tmp.join("benilla"); + std::fs::create_dir_all(primary.join(".git/worktrees/pool-3")).unwrap(); + let slot = tmp.join("pool-3"); + std::fs::create_dir_all(&slot).unwrap(); + let gitdir = primary.join(".git/worktrees/pool-3"); + std::fs::write(slot.join(".git"), format!("gitdir: {}\n", gitdir.display())).unwrap(); + assert_eq!(shared_root(&slot), primary); + assert_eq!(shared_root(&primary), primary); + let hello_mod = tmp.join("hello-mod"); + std::fs::create_dir_all(hello_mod.join(".git")).unwrap(); + assert_eq!(shared_root(&hello_mod), hello_mod); + let plain = tmp.join("plain"); + std::fs::create_dir_all(&plain).unwrap(); + assert_eq!(shared_root(&plain), plain); + std::fs::remove_dir_all(&tmp).ok(); + } + + /// A launcher's recorded folder is where a dev build keeps its state and reads its probe + /// identity; a player build resolves neither from it. Recording is process-wide, so the parent + /// runs this same test in a child process of the test binary, which records and resolves. + #[test] + fn a_recorded_launcher_folder_holds_the_state_and_the_identity() { + const CHILD: &str = "BENILLA_TEST_LAUNCHER_FOLDER"; + if let Some(dir) = std::env::var_os(CHILD) { + let dir = PathBuf::from(dir); + benilla_formats::set_project_folder(dir.to_str().unwrap()); + if crate::run_mode::dev_affordances() { + assert_eq!(home(), Some(dir.join(STATE_DIR))); + assert_eq!( + crate::run_mode::declared_identity(), + Some(crate::run_mode::DeclaredIdentity { + user: "probe9".into(), + character: "Modchar".into() + }) + ); + } else { + let exe_dir = std::env::current_exe().unwrap(); + assert_eq!(home(), Some(exe_dir.parent().unwrap().join(STATE_DIR))); + assert_eq!(crate::run_mode::declared_identity(), None); + } + return; + } + let tmp = std::env::temp_dir().join(format!("benilla-launcher-{}", std::process::id())); + std::fs::remove_dir_all(&tmp).ok(); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write( + tmp.join(".probe-identity"), + "WOW_USER=probe9\nWOW_PASS=unused\nWOW_CHAR=Modchar\n", + ) + .unwrap(); + let name = + "local_state::tests::a_recorded_launcher_folder_holds_the_state_and_the_identity"; + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", name, "--nocapture"]) + .env(CHILD, &tmp) + .env_remove("BENILLA_HOME") + .env_remove("WOW_CAPTURE") + .output() + .unwrap(); + let report = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + out.status.success() && report.contains("1 passed"), + "the child run:\n{report}" + ); + std::fs::remove_dir_all(&tmp).ok(); + } + #[test] fn write_atomic_creates_dirs_and_replaces_whole_files() { let _l = ENV_LOCK diff --git a/crates/benilla-app/src/run_mode.rs b/crates/benilla-app/src/run_mode.rs index 3b9b02de..2a8b7f55 100644 --- a/crates/benilla-app/src/run_mode.rs +++ b/crates/benilla-app/src/run_mode.rs @@ -127,9 +127,9 @@ pub(crate) fn free_fly_hint() -> String { /// This crate's source directory on a dev build, `None` in a player build. The `cfg` must be real, /// not a runtime check: `env!("CARGO_MANIFEST_DIR")` names the build machine, and the literal must -/// be absent from a player binary. Read by [`crate::ui_script`] (live `assets/ui`) and -/// [`crate::local_state`] (the project-folder home); held by -/// [`tests::the_dev_plane_has_exactly_one_door`]. +/// be absent from a player binary. Read by [`crate::ui_script`] for the live `assets/ui`, which are +/// benilla's own files whoever the launcher is; the launcher's folder is +/// [`benilla_formats::project_folder`]. Held by [`tests::the_dev_plane_has_exactly_one_door`]. pub(crate) fn dev_source_dir() -> Option<&'static std::path::Path> { #[cfg(feature = "dev")] { @@ -158,23 +158,15 @@ pub(crate) struct DeclaredIdentity { pub(crate) character: String, } -/// The declaration, read off the project folder; a player build has none. -#[cfg(feature = "dev")] +/// The declaration, read off the launcher's project folder ([`benilla_formats::project_folder`]); +/// a player build has none. pub(crate) fn declared_identity() -> Option { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2)?; + let root = benilla_formats::project_folder()?; parse_identity(&std::fs::read_to_string(root.join(".probe-identity")).ok()?) } -#[cfg(not(feature = "dev"))] -pub(crate) fn declared_identity() -> Option { - None -} - /// The file as an identity, `None` unless both the account and the character are there; the /// password is never read. -#[cfg_attr(not(feature = "dev"), allow(dead_code))] fn parse_identity(text: &str) -> Option { let field = |key: &str| { text.lines() diff --git a/crates/benilla-buildstamp/src/lib.rs b/crates/benilla-buildstamp/src/lib.rs index a6bebeac..9ce0f46c 100644 --- a/crates/benilla-buildstamp/src/lib.rs +++ b/crates/benilla-buildstamp/src/lib.rs @@ -6,7 +6,7 @@ use std::path::Path; use std::process::Command; -/// Stamp the commit this binary was built from into the calling package as five `rustc-env` +/// Stamp the commit this binary was built from into the calling package as six `rustc-env` /// vars, which its `main.rs` reads back with `env!` into a `BuildId`, beside its own /// `CARGO_PKG_VERSION`: /// @@ -17,6 +17,9 @@ use std::process::Command; /// (`v0.2.0-12-gb17be27`), which says how far past its release the commit is. /// - `BENILLA_PROFILE`: the profile directory's name, which tells `ship` from `release` where /// cargo's `PROFILE` does not. +/// - `BENILLA_PROJECT_DIR`: the folder a dev build keeps its `WoW` link, `benilla-config/` and +/// `.probe-identity` in, the calling package's workspace root ([`workspace_root`]); empty unless +/// the package's own `dev` feature is on, so a player binary carries no source path. /// /// The git vars are empty when git cannot answer (no `.git`, no `git` on `PATH`, no tag in /// reach), which the runtime reports as an unknown commit or the bare version. The rerun @@ -84,4 +87,65 @@ pub fn emit() { .map(str::to_owned) .unwrap_or_else(|| std::env::var("PROFILE").unwrap_or_default()); println!("cargo::rustc-env=BENILLA_PROFILE={profile}"); + + let project = if std::env::var_os("CARGO_FEATURE_DEV").is_some() { + workspace_root(Path::new(&dir)).display().to_string() + } else { + String::new() + }; + println!("cargo::rustc-env=BENILLA_PROJECT_DIR={project}"); +} + +/// The workspace root of the package at `manifest_dir`: the nearest folder up from it holding +/// `Cargo.lock`, which cargo writes at the root before any build script runs, else the package's +/// own folder. For a launcher inside benilla's checkout that is the checkout. +fn workspace_root(manifest_dir: &Path) -> &Path { + manifest_dir + .ancestors() + .find(|d| d.join("Cargo.lock").is_file()) + .unwrap_or(manifest_dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_workspace_root_is_the_nearest_folder_holding_the_lockfile() { + let tmp = std::env::temp_dir().join(format!("benilla-stamp-{}", std::process::id())); + let member = tmp.join("ws/crates/launcher"); + std::fs::create_dir_all(&member).unwrap(); + // A lockfile further up belongs to some other tree and must not win over the nearer one. + std::fs::write(tmp.join("Cargo.lock"), "").unwrap(); + std::fs::write(tmp.join("ws/Cargo.lock"), "").unwrap(); + assert_eq!(workspace_root(&member), tmp.join("ws")); + // A package that is its own workspace keeps its lockfile beside its manifest. + std::fs::write(member.join("Cargo.lock"), "").unwrap(); + assert_eq!(workspace_root(&member), member); + std::fs::remove_dir_all(&tmp).ok(); + + let bare = std::env::temp_dir().join(format!("benilla-stamp-bare-{}", std::process::id())); + std::fs::create_dir_all(&bare).unwrap(); + let root = workspace_root(&bare); + assert!( + root == bare || root.join("Cargo.lock").is_file(), + "no lockfile anywhere up means the package's own folder: {}", + root.display() + ); + std::fs::remove_dir_all(&bare).ok(); + } + + /// benilla's own launchers stamp the checkout, the folder a dev build always used. + #[test] + fn a_launcher_in_this_checkout_stamps_the_checkout() { + let shim = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("benilla"); + let checkout = Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap(); + assert_eq!(workspace_root(&shim), checkout); + } } diff --git a/crates/benilla-formats/Cargo.toml b/crates/benilla-formats/Cargo.toml index e9495141..fe96d643 100644 --- a/crates/benilla-formats/Cargo.toml +++ b/crates/benilla-formats/Cargo.toml @@ -9,8 +9,8 @@ description = "WoW 1.12.1 asset-format adapters (MPQ/BLP/DBC/ADT/WDT) and the be workspace = true [features] -# `dev` gates one thing, `install::candidates`' project-folder probe, which bakes this source -# tree's path into the binary. Every dependent takes this crate without default features and +# `dev` gates one thing, the project folder (`install::project_folder`), whose fallback bakes this +# source tree's path into the binary. Every dependent takes this crate without default features and # forwards `dev`, or unification puts the path back into a player build. default = ["dev"] dev = [] diff --git a/crates/benilla-formats/src/install.rs b/crates/benilla-formats/src/install.rs index 5d4117ff..cab28469 100644 --- a/crates/benilla-formats/src/install.rs +++ b/crates/benilla-formats/src/install.rs @@ -3,7 +3,7 @@ //! //! 1. `$WOW_DATA`. Set and empty (`WOW_DATA=`) means no install and ends the search, so the //! no-install path runs on a machine that has one. -//! 2. `/WoW/Data`, `dev` builds only, from this crate's `CARGO_MANIFEST_DIR`. +//! 2. `/WoW/Data`, `dev` builds only ([`project_folder`]). //! 3. `/Data`, then `/WoW/Data`, the release layouts. //! //! `dev` is a default feature here, so every dependent declares `default-features = false` and @@ -12,6 +12,44 @@ //! catch it. use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// The launcher's project folder, handed in by its entry point ([`set_project_folder`]). +static LAUNCHER_FOLDER: OnceLock = OnceLock::new(); + +/// Record the launcher's project folder, as its build stamp names it (`BuildId::project_dir`); an +/// empty stamp, a player build's, records nothing. The entry point calls this once, before +/// anything resolves from the folder, and a later call changes nothing. +pub fn set_project_folder(dir: &str) { + if !dir.is_empty() { + let _ = LAUNCHER_FOLDER.set(PathBuf::from(dir)); + } +} + +/// The folder a dev build keeps its `WoW` link, `benilla-config/` and `.probe-identity` in: the +/// launcher's, when its entry point recorded one, else benilla's own checkout, so a crate built on +/// top of benilla resolves from its folder and not from cargo's checkout of benilla. `None` in a +/// player build, which looks in no source tree. +pub fn project_folder() -> Option<&'static Path> { + #[cfg(feature = "dev")] + { + LAUNCHER_FOLDER + .get() + .map(PathBuf::as_path) + .or_else(benilla_checkout) + } + #[cfg(not(feature = "dev"))] + { + None + } +} + +/// benilla's own checkout: this crate's manifest dir, two levels up. The addon corpus always +/// resolves here, since its tests are benilla's. +#[cfg(feature = "dev")] +fn benilla_checkout() -> Option<&'static Path> { + Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) +} /// The vanilla `Data` directory, or `None` when there is no install. Uncached, so a `$WOW_DATA` /// change is seen. @@ -23,6 +61,7 @@ pub fn wow_data() -> Option { pub fn candidates() -> Vec { candidates_from( std::env::var_os("WOW_DATA").map(PathBuf::from), + project_folder(), std::env::current_exe() .ok() .and_then(|e| e.parent().map(Path::to_path_buf)), @@ -31,7 +70,11 @@ pub fn candidates() -> Vec { /// [`candidates`] with the environment passed in: a test that set `$WOW_DATA` would change the /// answer for every test running beside it. `tests/wow_data_env.rs` covers the real read. -fn candidates_from(override_dir: Option, exe_dir: Option) -> Vec { +fn candidates_from( + override_dir: Option, + project: Option<&Path>, + exe_dir: Option, +) -> Vec { let mut out = Vec::with_capacity(4); // 1. The override; set and empty is no install at all, even in a dev build @@ -43,10 +86,8 @@ fn candidates_from(override_dir: Option, exe_dir: Option) -> V out.push(over); } - // 2. The project folder, dev builds only: this crate's manifest dir, two levels up, is the - // workspace root for every caller. - #[cfg(feature = "dev")] - if let Some(root) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) { + // 2. The project folder, dev builds only. + if let Some(root) = project { out.push(root.join("WoW/Data")); } @@ -113,8 +154,9 @@ fn skipped_under(required: bool, what: &str, looked_in: &[PathBuf]) { } /// The third-party addon corpus the UI engine's real-addon tests run against, or `None`: -/// `$BENILLA_ADDON_CORPUS`, then `/wow-addons-vanilla` (`dev` only), a gitignored -/// link beside `WoW`. Never a folder beside the checkout, which depends on where the checkout sits. +/// `$BENILLA_ADDON_CORPUS`, then `/wow-addons-vanilla` (`dev` only), a +/// gitignored link beside `WoW`. Never a folder beside the checkout, which depends on where the +/// checkout sits. pub fn addon_corpus() -> Option { addon_corpus_candidates().into_iter().find(|c| c.is_dir()) } @@ -130,9 +172,9 @@ fn addon_corpus_candidates_from(override_dir: Option) -> Vec { if let Some(over) = override_dir { out.push(over); } - // The project folder, dev builds only, as the install's rung 2. + // benilla's checkout, dev builds only, whoever the launcher is: the corpus tests are benilla's. #[cfg(feature = "dev")] - if let Some(root) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) { + if let Some(root) = benilla_checkout() { out.push(root.join("wow-addons-vanilla")); } out @@ -162,9 +204,14 @@ macro_rules! addon_corpus_or_skip { mod tests { use super::*; - /// No test here touches the process environment (see [`candidates_from`]). + /// No test here touches the process environment (see [`candidates_from`]), or records a + /// launcher's folder, which is process-wide (`tests/project_folder.rs` has a process of its own). fn probe(over: Option<&str>, exe: Option<&str>) -> Vec { - candidates_from(over.map(PathBuf::from), exe.map(PathBuf::from)) + candidates_from( + over.map(PathBuf::from), + project_folder(), + exe.map(PathBuf::from), + ) } #[test] @@ -195,7 +242,7 @@ mod tests { // The winner depends on the build (dev puts the project folder first), so assert only // that it is not the ghost and exists. - let chosen = candidates_from(Some(ghost.clone()), Some(tmp.clone())) + let chosen = candidates_from(Some(ghost.clone()), project_folder(), Some(tmp.clone())) .into_iter() .find(|c| c.is_dir()); assert_ne!( @@ -224,6 +271,11 @@ mod tests { #[cfg(not(feature = "dev"))] #[test] fn a_player_build_carries_no_source_tree_path() { + assert_eq!( + project_folder(), + None, + "a player build has no project folder, whatever its launcher stamped" + ); let root = env!("CARGO_MANIFEST_DIR"); for c in probe(None, Some("/games/benilla")) { assert!( @@ -234,6 +286,8 @@ mod tests { } } + /// With no launcher folder recorded (a test, an example, a tool) the rung is benilla's own + /// checkout; with one, it is that folder's, and benilla's checkout is not looked in. #[cfg(feature = "dev")] #[test] fn a_dev_build_looks_in_the_project_folder() { @@ -241,17 +295,34 @@ mod tests { .ancestors() .nth(2) .unwrap(); + assert_eq!( + project_folder(), + Some(root), + "nothing recorded: the checkout" + ); let c = probe(None, Some("/games/benilla")); assert!( c.contains(&root.join("WoW/Data")), "the dev build lost its project-folder candidate ({}): {c:?}", root.display() ); + + let launcher = Path::new("/home/player/hello-mod"); + let c = candidates_from(None, Some(launcher), Some(PathBuf::from("/games/benilla"))); + assert_eq!( + c, + vec![ + launcher.join("WoW/Data"), + PathBuf::from("/games/benilla/Data"), + PathBuf::from("/games/benilla/WoW/Data"), + ], + "a launcher's folder takes the rung, and nothing else looks in benilla's checkout" + ); } #[cfg(feature = "dev")] #[test] - fn the_corpus_ladder_is_override_then_the_project_folder_and_nothing_outside_it() { + fn the_corpus_ladder_is_override_then_benillas_checkout_and_nothing_outside_it() { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(2) diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 4975d51d..91a12667 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -18,7 +18,10 @@ mod tga; pub use tga::tga_to_rgba; /// Where the WoW install is; [`Chain`] opens it. mod install; -pub use install::{addon_corpus, addon_corpus_candidates, candidates, skipped, wow_data}; +pub use install::{ + addon_corpus, addon_corpus_candidates, candidates, project_folder, set_project_folder, skipped, + wow_data, +}; mod characters; pub use characters::{ equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, forearm_dressed, diff --git a/crates/benilla-formats/tests/project_folder.rs b/crates/benilla-formats/tests/project_folder.rs new file mode 100644 index 00000000..f3327cdf --- /dev/null +++ b/crates/benilla-formats/tests/project_folder.rs @@ -0,0 +1,45 @@ +//! A launcher's recorded project folder is where a dev build looks for the install, in place of +//! benilla's checkout, while the addon corpus stays benilla's. Recording is process-wide and every +//! test resolves through it, so this one test has a file, and so a process, of its own. + +use std::path::Path; + +#[test] +fn a_recorded_launcher_folder_is_where_a_dev_build_looks() { + let tmp = std::env::temp_dir().join(format!("benilla-project-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + // This process's own environment: `WOW_DATA=` would empty the ladder, and an override would + // move the corpus. + std::env::remove_var("WOW_DATA"); + std::env::remove_var("BENILLA_ADDON_CORPUS"); + let checkout = Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap(); + + // A player build's stamp is empty and records nothing. + benilla_formats::set_project_folder(""); + if cfg!(feature = "dev") { + assert_eq!(benilla_formats::project_folder(), Some(checkout)); + } + + benilla_formats::set_project_folder(tmp.to_str().unwrap()); + benilla_formats::set_project_folder("/a/later/call/changes/nothing"); + if cfg!(feature = "dev") { + assert_eq!(benilla_formats::project_folder(), Some(tmp.as_path())); + let c = benilla_formats::candidates(); + assert_eq!(c.first(), Some(&tmp.join("WoW/Data")), "{c:?}"); + assert!( + !c.contains(&checkout.join("WoW/Data")), + "benilla's checkout is still looked in for the install: {c:?}" + ); + assert_eq!( + benilla_formats::addon_corpus_candidates(), + vec![checkout.join("wow-addons-vanilla")], + "the corpus is benilla's whoever the launcher is" + ); + } else { + assert_eq!(benilla_formats::project_folder(), None); + } + std::fs::remove_dir_all(&tmp).ok(); +} diff --git a/crates/benilla-world/src/build_id.rs b/crates/benilla-world/src/build_id.rs index d629ac33..c9438779 100644 --- a/crates/benilla-world/src/build_id.rs +++ b/crates/benilla-world/src/build_id.rs @@ -1,7 +1,9 @@ //! Which build is running: the release and the commit the binary was built from, stamped at //! compile time by the launcher shims' build script (`benilla-buildstamp`) and passed to `run` as //! the [`BuildId`] resource, and whether a crate on top of benilla extended it. It shows in the -//! startup log line ([`banner`]), in crash reports and in the debug panel's footer. +//! startup log line ([`banner`]), in crash reports and in the debug panel's footer. A dev build's +//! stamp also names the launcher's project folder, which the entry point hands to +//! `benilla_formats::set_project_folder`. use bevy::prelude::*; @@ -22,6 +24,9 @@ pub struct BuildId { pub date: &'static str, /// The cargo profile directory: `debug`, `release` or `ship`. pub profile: &'static str, + /// The launcher's project folder in a dev build, where its `WoW` link, `benilla-config/` and + /// `.probe-identity` live; empty in a player build, which carries no source path. + pub project_dir: &'static str, /// Whether a crate on top of benilla added plugins through `benilla_app::run_with`. The entry /// point sets it, so a launcher leaves it to `..Default::default()`. pub extended: bool, @@ -91,6 +96,7 @@ mod tests { short, date: "2026-09-27", profile: "release", + project_dir: "", extended: false, } } diff --git a/crates/benilla-world/src/worldview.rs b/crates/benilla-world/src/worldview.rs index d4af7194..0dc4cc31 100644 --- a/crates/benilla-world/src/worldview.rs +++ b/crates/benilla-world/src/worldview.rs @@ -52,6 +52,9 @@ const FOVY: f32 = 1.221_730_5; /// Builds and runs the world viewer; `build` is the launcher shim's compile-time git stamp. pub fn run(build: BuildId) -> AppExit { + // The install resolves from the launcher's folder when its stamp names one, as the client's + // does; this shim has no `dev` feature, so it stamps none and the viewer keeps benilla's. + benilla_formats::set_project_folder(build.project_dir); let mut app = App::new(); app.insert_resource(build); diff --git a/crates/benilla-worldview/src/main.rs b/crates/benilla-worldview/src/main.rs index ef17087f..9229ee17 100644 --- a/crates/benilla-worldview/src/main.rs +++ b/crates/benilla-worldview/src/main.rs @@ -12,6 +12,7 @@ fn main() -> benilla_world::AppExit { short: env!("BENILLA_GIT_SHORT"), date: env!("BENILLA_GIT_DATE"), profile: env!("BENILLA_PROFILE"), + project_dir: env!("BENILLA_PROJECT_DIR"), ..Default::default() }) } diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index f034c886..266130ac 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -11,6 +11,7 @@ fn main() -> benilla_app::AppExit { short: env!("BENILLA_GIT_SHORT"), date: env!("BENILLA_GIT_DATE"), profile: env!("BENILLA_PROFILE"), + project_dir: env!("BENILLA_PROJECT_DIR"), ..Default::default() }) } diff --git a/docs/MAP.md b/docs/MAP.md index 202585be..a8537433 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -212,7 +212,7 @@ - `WOW_CULL_TRACE` — benilla-world/src/exterior_cull.rs - `WOW_CULLDUMP` — benilla-world/src/wmo_portal/mod.rs - `WOW_CURSOR_TRACE` — benilla-app/src/cursor.rs -- `WOW_DATA` — benilla-app/src/local_state.rs, benilla-formats/src/install.rs, benilla-formats/tests/wow_data_env.rs, benilla-formats/tests/wow_data_none.rs +- `WOW_DATA` — benilla-app/src/local_state.rs, benilla-formats/src/install.rs, benilla-formats/tests/project_folder.rs, benilla-formats/tests/wow_data_env.rs, benilla-formats/tests/wow_data_none.rs - `WOW_DEMO_BOTTOM_BARS` — benilla-app/src/ui_script/mod.rs - `WOW_DEPTH` — benilla-app/src/capture/depth_probe.rs, benilla-app/src/dev.rs, benilla-world/src/bgwin.rs - `WOW_DEPTH_AFTER` — benilla-app/src/capture/depth_probe.rs