diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 1a0e200b..f2e84cdc 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -253,6 +253,15 @@ fn launch(build: BuildId, extend: Option>) -> AppExit { // 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); + if run_mode::unattended() && std::env::var_os("BENILLA_HOME").is_none() { + // Before the logger exists; whoever reads what the run wrote needs the folder. + if let Some(home) = local_state::home() { + println!( + "state: an unattended run keeps its own state in {} (BENILLA_HOME pins one)", + home.display() + ); + } + } // From here a panic leaves `benilla-config/Diagnostics/crash-.txt`; armed before the // `App` exists, so a panic while plugins build is reported too. diff --git a/crates/benilla-app/src/local_state.rs b/crates/benilla-app/src/local_state.rs index 11ff4869..b448dabb 100644 --- a/crates/benilla-app/src/local_state.rs +++ b/crates/benilla-app/src/local_state.rs @@ -4,9 +4,12 @@ //! //! Resolution, in order, the same shape as [`benilla_formats::wow_data`]: //! 1. `$BENILLA_HOME`. -//! 2. `/benilla-config/`, dev builds only ([`benilla_formats::project_folder`], the +//! 2. An unattended run ([`crate::run_mode::unattended`]): a fresh folder of its own for the run, +//! under the system temp folder, so a probe neither reads the player's settings nor writes back +//! over them. +//! 3. `/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/`. +//! 4. `/benilla-config/`. //! //! With `$WOW_CAPTURE` set every path resolves to `None`: a capture neither reads nor writes //! player state. @@ -22,25 +25,45 @@ const STATE_DIR: &str = "benilla-config"; /// It may not exist yet: [`write_atomic`] creates it, and a reader treats a missing file as /// defaults. pub(crate) fn home() -> Option { + home_in(cfg!(test)) +} + +/// [`home`], with the unit-test stop as an argument so a test can walk the steps behind it. +fn home_in(unit_test: bool) -> Option { if std::env::var_os("WOW_CAPTURE").is_some() { return None; // hermetic: captures neither read nor write player state } if let Some(over) = std::env::var_os("BENILLA_HOME") { return Some(PathBuf::from(over)); } - if cfg!(test) { + if unit_test { return None; // a unit test reaches no real state folder unless it pins `$BENILLA_HOME` } + if crate::run_mode::unattended() { + return Some(run_home().to_path_buf()); + } resident_home() } -/// Steps 2 and 3 of [`home`]: the folder the build itself lives in. +/// Step 2 of [`home`]: the unattended run's own folder, one per process, created on first write. +/// Left behind for whoever reads what the run wrote; the system cleans its temp folder. +pub(crate) fn run_home() -> &'static Path { + static RUN_HOME: std::sync::OnceLock = std::sync::OnceLock::new(); + RUN_HOME.get_or_init(|| run_home_in(&std::env::temp_dir(), std::process::id())) +} + +/// [`run_home`]'s path under `temp` for process `pid`. +fn run_home_in(temp: &Path, pid: u32) -> PathBuf { + temp.join(format!("benilla-run-{pid}")).join(STATE_DIR) +} + +/// Steps 3 and 4 of [`home`]: the folder the build itself lives in. fn resident_home() -> Option { - // 2 · the project folder, dev builds only. + // 3 · the project folder, dev builds only. if let Some(root) = dev_project_root() { return Some(root.join(STATE_DIR)); } - // 3 · beside the binary; a dev build never reaches here. + // 4 · beside the binary; a dev build never reaches here. std::env::current_exe() .ok() .and_then(|e| e.parent().map(Path::to_path_buf)) @@ -384,6 +407,46 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + /// An unattended run keeps its state in a folder of its own, never the player's, unless + /// `$BENILLA_HOME` pins one; a capture still resolves none. + #[test] + fn an_unattended_run_keeps_its_own_state_folder() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _c = EnvGuard::unset("WOW_CAPTURE"); + let _r = EnvGuard::unset("WOW_RIG"); + let _h = EnvGuard::unset("BENILLA_HOME"); + let _u = EnvGuard::set("WOW_UNATTENDED", "1"); + let run = home_in(false).expect("an unattended run has a state folder"); + assert_eq!(run, run_home()); + assert_ne!( + Some(&run), + resident_home().as_ref(), + "never the player's folder" + ); + assert_eq!( + home_in(false), + Some(run.clone()), + "one folder for the whole run" + ); + assert_eq!( + run_home_in(Path::new("/tmp"), 42), + Path::new("/tmp/benilla-run-42/benilla-config") + ); + + // A rig is unattended too. + let _u2 = EnvGuard::unset("WOW_UNATTENDED"); + let _r2 = EnvGuard::set("WOW_RIG", "tauren druid 60"); + assert_eq!(home_in(false), Some(run)); + + // The override still wins, and a capture still resolves nothing. + let _h2 = EnvGuard::set("BENILLA_HOME", "/tmp/benilla-pinned"); + assert_eq!(home_in(false), Some(PathBuf::from("/tmp/benilla-pinned"))); + let _c2 = EnvGuard::set("WOW_CAPTURE", "ui-options"); + assert_eq!(home_in(false), None); + } + /// A unit test that does not pin `$BENILLA_HOME` resolves no state folder, so no test can write /// the real one. #[test] diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 7d976f38..68b22847 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -100,6 +100,9 @@ out of scope is closed with the reason. - `WOW_UNATTENDED=1` reconnects instead of waiting at a dialog, and exits non-zero on a login it cannot pass. `WOW_NOSOUND=1` runs silent. A capture (`WOW_CAPTURE`) is both, and a rig (`WOW_RIG`) is unattended. `WOW_ALLOW_ACCOUNT=1` overrides the account guard. + - An unattended run starts from defaults in a fresh state folder of its own, printed at boot + (`state:`), so it never reads or overwrites your settings, layout or addons. `BENILLA_HOME` + pins a folder, for a run that needs addons, seeded files or state kept across runs. - On vmangos, `WOW_RIG="tauren druid 60 gear:heal-preraid-bis spec:heal-preraid-bis at:ThunderBluff"` finds or creates that character on the account and applies the server's premade sets (`WOW_RIG="gear:?"` lists them). `WOW_PROBE_CHAT=""` sends one GM diff --git a/docs/MAP.md b/docs/MAP.md index b1e708b3..9c61e52f 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -454,7 +454,7 @@ - `WOW_RES_CENSUS` — benilla-app/src/perf/census.rs - `WOW_RESIZE` — benilla-app/src/capture/mod.rs - `WOW_REVEAL` — benilla-app/src/capture/probes/reveal.rs, benilla-app/src/dev.rs -- `WOW_RIG` — benilla-app/src/capture/probe_rig.rs, benilla-app/src/dev.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/probe_shield.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs, benilla-world/src/bgwin.rs +- `WOW_RIG` — benilla-app/src/capture/probe_rig.rs, benilla-app/src/dev.rs, benilla-app/src/local_state.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/probe_shield.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs, benilla-world/src/bgwin.rs - `WOW_RIG_CENSUS` — benilla-world/src/rig_palette.rs - `WOW_RIG_COST` — benilla-world/src/rig_palette.rs - `WOW_RIG_SKIN` — benilla-assets/src/materials.rs @@ -496,7 +496,7 @@ - `WOW_UI_SCALE` — benilla-app/src/cvars/mod.rs, benilla-app/src/ui_script/mod.rs - `WOW_UI_SPLICE_VERIFY` — benilla-app/src/ui_script/extract/mod.rs - `WOW_UISCRIPT_DUMP` — benilla-app/tests/ui_script_wall.rs -- `WOW_UNATTENDED` — benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/run_mode.rs, benilla-world/src/bgwin.rs +- `WOW_UNATTENDED` — benilla-app/src/local_state.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/run_mode.rs, benilla-world/src/bgwin.rs - `WOW_UNIT_VISUALS` — benilla-app/src/capture/probes/visual_census.rs, benilla-app/src/dev.rs - `WOW_UNIT_VISUALS_RADIUS` — benilla-app/src/capture/probes/visual_census.rs - `WOW_UPLOAD_BUDGET` — benilla-world/src/world_plugins.rs