diff --git a/crates/benilla-visual/src/main.rs b/crates/benilla-visual/src/main.rs index f0067931..3aa91a38 100644 --- a/crates/benilla-visual/src/main.rs +++ b/crates/benilla-visual/src/main.rs @@ -140,12 +140,22 @@ fn main() -> Result<()> { } "diff-dir" => { let [da, db] = two(rest, "diff-dir")?; - let worst = diff_dir( + let DirDiff { worst, unpaired } = diff_dir( Path::new(da), Path::new(db), opts.out.as_deref(), opts.amplify, )?; + // Under `--fail` this is a gate, and a shot that never landed must sink it — see the + // note in `diff_dir` (decision 0743). + if opts.fail.is_some() && !unpaired.is_empty() { + bail!( + "{} unpaired image(s) — a shot is missing from one side, so this comparison is \ + incomplete: {}", + unpaired.len(), + unpaired.join(", ") + ); + } if let Some((name, m)) = worst { if over_fail(&m, opts.fail) { bail!( @@ -679,13 +689,16 @@ fn diff_one(a: &Path, b: &Path, out: Option<&Path>, amplify: u32) -> Result, - amplify: u32, -) -> Result> { - let names: BTreeSet = pngs(da)?.intersection(&pngs(db)?).cloned().collect(); +/// What one `diff-dir` run found: the worst pair by MAE, and every image present on only ONE side. +struct DirDiff { + worst: Option<(String, Metrics)>, + /// Files with no counterpart, so not diffed at all — see the note in [`diff_dir`]. + unpaired: Vec, +} + +fn diff_dir(da: &Path, db: &Path, out: Option<&Path>, amplify: u32) -> Result { + let (a, b) = (pngs(da)?, pngs(db)?); + let names: BTreeSet = a.intersection(&b).cloned().collect(); if names.is_empty() { bail!( "no common *.png files between {} and {}", @@ -693,6 +706,19 @@ fn diff_dir( db.display() ); } + // UNPAIRED shots are ANNOUNCED, never skipped in silence. Pairing on the intersection alone is + // how a sweep reports all-green with a shot missing: on 2026-07-28 one `water-night` capture + // exited 0 without writing its PNG, and `selfcheck` diffed the other eight and passed clean. A + // gate that quietly narrows its own scope is worse than no gate. Reported always; fatal to the + // CALLER when `--fail` is in force, i.e. whenever this is being used as a gate (decision 0743). + let unpaired: Vec = a + .difference(&b) + .map(|n| format!("{}/{n}", da.display())) + .chain(b.difference(&a).map(|n| format!("{}/{n}", db.display()))) + .collect(); + for u in &unpaired { + eprintln!("UNPAIRED (no counterpart — NOT diffed): {u}"); + } if let Some(out) = out { std::fs::create_dir_all(out).ok(); } @@ -708,7 +734,7 @@ fn diff_dir( if let Some((name, m)) = &worst { println!("worst: {name} (MAE {:.3})", m.mae); } - Ok(worst) + Ok(DirDiff { worst, unpaired }) } fn pngs(dir: &Path) -> Result> { diff --git a/crates/benilla/src/capture/mod.rs b/crates/benilla/src/capture/mod.rs index 33b55b4d..8e2dc194 100644 --- a/crates/benilla/src/capture/mod.rs +++ b/crates/benilla/src/capture/mod.rs @@ -376,7 +376,8 @@ impl Plugin for CapturePlugin { .init_resource::(); Scenario { name: "fxview", - eye: GROUND_EYE, // overridden per frame by the orbit in `pin_scene` + map: scenarios::MAP_AZEROTH, // the fixture spawns over the Northshire slope + eye: GROUND_EYE, // overridden per frame by the orbit in `pin_scene` look: FXVIEW_POS, minute: 720, ui: None, @@ -418,6 +419,7 @@ impl Plugin for CapturePlugin { .init_resource::(); Scenario { name: "waterfx", + map: scenarios::MAP_AZEROTH, // the synthetic lattice sits over the Northshire slope eye, look: center, minute: 720, @@ -459,6 +461,12 @@ impl Plugin for CapturePlugin { let d = 500.0_f32; Scenario { name: "vista", + // The arbitrary-viewpoint instrument goes anywhere, so its map is a knob: a + // horizon report from Kalimdor is `WOW_MAP=1` (a `Map.dbc` id). + map: std::env::var("WOW_MAP") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(scenarios::MAP_AZEROTH), eye, look: [ eye[0] + d * pitch.cos() * face.cos(), @@ -487,6 +495,14 @@ impl Plugin for CapturePlugin { ); std::process::exit(2); }; + // Seed the continent the scenario names, BEFORE `world_map::load_world_map` reads it at + // `Startup` — that is the single place `CurrentMap` is set for a server-less run, and the + // terrain/WDL streamers and per-map lighting all key off it. Raw WoW coords repeat on every + // continent, so a scenario that could not say which map it meant would stream the wrong + // world's tiles and photograph a void (Felwood's tile `33_24` exists in Azeroth, empty). + // Written back for `vista` too, which is where the value came from — a harmless no-op that + // keeps one path for "which map is this run on" (decision 0743). + std::env::set_var("WOW_MAP", scenario.map.to_string()); let out = std::env::var("WOW_CAPTURE_OUT") .unwrap_or_else(|_| format!("target/visual/{}.png", scenario.name)); if let Some(parent) = Path::new(&out).parent() { @@ -850,8 +866,23 @@ fn drive_capture( } Phase::Done(n) => { if n + 1 >= EXIT_GRACE_FRAMES { - info!("capture: saved {}, exiting", ctx.out); - exit.write(AppExit::Success); + // Exit on what is ON DISK, not on having reached this phase. The save is async and + // the `Saving` phase gives up after `SAVE_TIMEOUT_FRAMES` so a missed `Capturing` + // marker can't hang the harness — but "gave up" used to exit Success anyway, so a + // capture that wrote nothing reported success and the sweep carried on around the + // hole (2026-07-28: one `water-night` run left no PNG, and `selfcheck` passed on + // the remaining eight). A missing file is a FAILED capture and says so, so + // `scripts/visual.sh`'s `set -e` stops the sweep at it (decision 0743). + if Path::new(&ctx.out).is_file() { + info!("capture: saved {}, exiting", ctx.out); + exit.write(AppExit::Success); + } else { + error!( + "capture: FAILED — no file at {} after the save window; exiting nonzero", + ctx.out + ); + exit.write(AppExit::error()); + } Phase::Done(n) } else { Phase::Done(n + 1) diff --git a/crates/benilla/src/capture/scenarios.rs b/crates/benilla/src/capture/scenarios.rs index caa29b32..b02f872a 100644 --- a/crates/benilla/src/capture/scenarios.rs +++ b/crates/benilla/src/capture/scenarios.rs @@ -7,6 +7,11 @@ #[derive(Clone, Copy)] pub(super) struct Scenario { pub(super) name: &'static str, + /// `Map.dbc` id the eye/look coords belong to. Raw WoW coords repeat on every continent — the + /// Felwood spot's tile (`33_24`) exists in Azeroth too, empty — so a scenario that could not + /// name its map silently photographed the wrong world. The harness seeds + /// [`crate::world_map::CurrentMap`] from this before streaming starts (decision 0743). + pub(super) map: u32, /// Camera eye, raw WoW coords `(x, y, z)`. pub(super) eye: [f32; 3], /// Camera look-at target, raw WoW coords. @@ -110,11 +115,16 @@ pub(super) const SKY_LOOK: [f32; 3] = [-8740.0, 80.0, 168.0]; // up + out: horiz // Abbey, one of the few buildings immune to it. Baselines must cover ordinary buildings too. pub(super) const HOUSE_EYE: [f32; 3] = [-9439.1, 71.2, 68.0]; -/// THE golden baseline: three spots FRAMED BY THE DIRECTOR in the live client ([`/shot`], the +/// `Map.dbc` ids the golden spots stand on. +pub(super) const MAP_AZEROTH: u32 = 0; +pub(super) const MAP_KALIMDOR: u32 = 1; + +/// THE golden baseline: five spots FRAMED BY THE DIRECTOR in the live client ([`/shot`], the /// numbers below verbatim from their `~/.benilla/shots.txt`) — the Northshire overlook toward the -/// Abbey, a Stormwind canal view, and Elwynn water — each at noon and at night. +/// Abbey, a Stormwind canal view, Elwynn water, the Lion's Pride Inn common room, and a Felwood +/// hollow — each at noon and at night, except the inn (which the clock barely reaches; see below). /// -/// Six shots of maximal mutual difference. The old set was thirty, and the director judged the +/// Nine shots of maximal mutual difference. The old set was thirty, and the director judged the /// design bad: low-value repetition (four compass looks at one farmhouse, five frames from one /// Northshire camera, sixteen UI windows over the same backdrop), each one a real window popping /// open on their screen while they worked. The law is **a few spots chosen with the director, times @@ -122,9 +132,17 @@ pub(super) const HOUSE_EYE: [f32; 3] = [-9439.1, 71.2, 68.0]; /// blessed sweep. (Decision 0632; ported from the sibling `substrate` fork's `notes/0025`, where the /// redesign was made and where it caught a real determinism bug — see `liquid::animate_liquid` — on /// its first run.) +/// +/// The first six were all EXTERIOR, all Azeroth, all Elwynn/Stormwind. The two spots added in +/// decision 0743 are the two things that set of six could not photograph at all: the inside of a +/// building (portal culling, the INT vertex bake, MOCV self-illum, MOLT point pools, props) and a +/// second continent (a different tileset, fog palette, liquid type and horizon). Both were framed +/// by the director after the agent's own framings of the same two subjects were judged bad — which +/// is 0632's rule working exactly as intended. pub(super) const SCENARIOS: &[Scenario] = &[ Scenario { name: "overlook-noon", + map: MAP_AZEROTH, eye: OVERLOOK_EYE, look: OVERLOOK_LOOK, minute: 720, @@ -132,6 +150,7 @@ pub(super) const SCENARIOS: &[Scenario] = &[ }, Scenario { name: "overlook-night", + map: MAP_AZEROTH, eye: OVERLOOK_EYE, look: OVERLOOK_LOOK, minute: 0, @@ -139,6 +158,7 @@ pub(super) const SCENARIOS: &[Scenario] = &[ }, Scenario { name: "canal-noon", + map: MAP_AZEROTH, eye: CANAL_EYE, look: CANAL_LOOK, minute: 720, @@ -146,6 +166,7 @@ pub(super) const SCENARIOS: &[Scenario] = &[ }, Scenario { name: "canal-night", + map: MAP_AZEROTH, eye: CANAL_EYE, look: CANAL_LOOK, minute: 0, @@ -153,6 +174,7 @@ pub(super) const SCENARIOS: &[Scenario] = &[ }, Scenario { name: "water-noon", + map: MAP_AZEROTH, eye: WATER_EYE, look: WATER_LOOK, minute: 720, @@ -160,11 +182,41 @@ pub(super) const SCENARIOS: &[Scenario] = &[ }, Scenario { name: "water-night", + map: MAP_AZEROTH, eye: WATER_EYE, look: WATER_LOOK, minute: 0, ui: None, }, + Scenario { + name: "inn-noon", + map: MAP_AZEROTH, + eye: INN_EYE, + look: INN_LOOK, + minute: 720, + ui: None, + }, + // No `inn-night`: MEASURED at MAE 0.198 against `inn-noon`, 0.82 % of pixels — the room is lit + // by its hearth and candles, and the clock barely reaches it (the outdoor spots' own noon/night + // pairs differ by MAE 38-83 across ~100 % of the frame). A second window for 0.8 % of one shot + // is the low-value repetition 0632 exists to prevent, and noon is the richer of the two anyway: + // it has the daylight window AND the fire. Re-add it the day the interior light path earns it. + Scenario { + name: "felwood-noon", + map: MAP_KALIMDOR, + eye: FELWOOD_EYE, + look: FELWOOD_LOOK, + minute: 720, + ui: None, + }, + Scenario { + name: "felwood-night", + map: MAP_KALIMDOR, + eye: FELWOOD_EYE, + look: FELWOOD_LOOK, + minute: 0, + ui: None, + }, ]; /// Director shot 1 — the Northshire overlook, north-east of the Abbey looking at it: terrain, @@ -178,17 +230,44 @@ pub(super) const CANAL_EYE: [f32; 3] = [-8871.7, 724.6, 110.7]; pub(super) const CANAL_LOOK: [f32; 3] = [-8836.7, 760.3, 112.3]; /// Director shot 3 — Elwynn river, south-east of Northshire: open water dominant, shoreline blend, -/// murloc camp, fog. Chosen by the director over the Goldshire inn interior, which stays in -/// [`ON_DEMAND`] as the interior-path fixture. +/// murloc camp, fog. pub(super) const WATER_EYE: [f32; 3] = [-9527.0, -310.6, 70.8]; pub(super) const WATER_LOOK: [f32; 3] = [-9499.4, -351.3, 61.4]; +/// Director shot 4 (decision 0743) — INSIDE the Lion's Pride Inn at Goldshire, standing in the +/// common room: the hearth (the MOCV-alpha self-illum bake), a daylight window, the lit chandelier, +/// the stair run, floor boards and ceiling beams, and a room full of props. The sweep's only +/// interior, and the only shot that exercises portal culling, the INT bake, the MOLT point pools +/// and WMO props at all — the surface the ledger's dungeon reports live on, and the one B101's +/// prop-cull regression (which hid the Blackrock lava falls) crossed unseen. +/// +/// The agent's own interior framing (`inn-interior`, a tight crop of the kitchen hearth) was judged +/// badly framed and stays in [`ON_DEMAND`]; this is the director's, and it frames the ROOM. +/// +/// NB the camera must stand over a FLOOR FACE: over a floorless pocket the portal cull's down-ray +/// reads "outside" and faithfully culls the containing group, which vanishes the room. +pub(super) const INN_EYE: [f32; 3] = [-9471.4, 39.4, 59.9]; +pub(super) const INN_LOOK: [f32; 3] = [-9458.8, -7.5, 48.2]; + +/// Director shot 5 (decision 0743) — a Felwood hollow on **Kalimdor** (`MAP_KALIMDOR`): the +/// corrupted forest floor's root mat, a stand of emissive `felwoodmushroom` doodads, a pool of +/// green sludge (a liquid type no other golden shot contains), the vast trunks behind, and the +/// zone's sick-green fog and light palette. +/// +/// The sweep's only shot outside Azeroth and outside the Elwynn/Stormwind palette — until this one +/// landed, a fog, tileset, WDL-horizon or per-map lighting regression anywhere else in the world +/// had nowhere to show up. It is also what forced [`Scenario::map`]: this spot's ADT tile +/// (`33_24`) exists in Azeroth too, empty, so on the old table it would have photographed a void. +pub(super) const FELWOOD_EYE: [f32; 3] = [4060.9, -944.3, 256.8]; +pub(super) const FELWOOD_LOOK: [f32; 3] = [4014.0, -954.4, 242.9]; + /// Every remaining named viewpoint — the UI look-pass fixtures, the sun/moon/sky regression /// fixtures, the house-compass and street scenes. Capturable by name (`WOW_CAPTURE=`) for /// debugging and look passes, but NOT part of the blessed baseline sweep. pub(super) const ON_DEMAND: &[Scenario] = &[ Scenario { name: "house-north", + map: MAP_AZEROTH, eye: HOUSE_EYE, look: [-9389.1, 71.2, 58.0], minute: 720, @@ -196,6 +275,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "house-south", + map: MAP_AZEROTH, eye: HOUSE_EYE, look: [-9489.1, 71.2, 58.0], minute: 720, @@ -203,6 +283,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "house-west", + map: MAP_AZEROTH, eye: HOUSE_EYE, look: [-9439.1, 121.2, 58.0], minute: 720, @@ -210,6 +291,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "house-east", + map: MAP_AZEROTH, eye: HOUSE_EYE, look: [-9439.1, 21.2, 58.0], minute: 720, @@ -225,6 +307,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // client does the same — the audit's "faithful-cull residue"), which vanishes the room. Scenario { name: "inn-interior", + map: MAP_AZEROTH, eye: [-9463.3, 4.4, 58.8], look: [-9462.1, -5.6, 58.5], minute: 720, @@ -235,6 +318,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // stays as the warm-light/fog fixture. Scenario { name: "northshire-dusk", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 1170, // 19:30 — warm dusk light + fog @@ -242,6 +326,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "northshire-sky-noon", + map: MAP_AZEROTH, eye: SKY_EYE, look: SKY_LOOK, minute: 720, // day sky-dome gradient + fog horizon @@ -249,6 +334,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "northshire-sky-dusk", + map: MAP_AZEROTH, eye: SKY_EYE, look: SKY_LOOK, minute: 1170, // dusk dome warp + low sun + stars emerging @@ -262,6 +348,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // was cut along a giant faceted circle). Scenario { name: "northshire-sun-flare", + map: MAP_AZEROTH, eye: SKY_EYE, look: [-8797.0, 23.0, 264.0], // eye + 300·(elev 30°, az 45°) — the sun's spot at 17:30 minute: 1050, @@ -276,6 +363,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // unit tests (`flare_ray_*`) and the sun fixtures. Scenario { name: "northshire-moonrise", + map: MAP_AZEROTH, eye: SKY_EYE, look: [-8775.0, 45.0, 190.0], // eye + 300·(elev 15°, az 45°) — the moon's spot at 22:44 minute: 1364, @@ -287,6 +375,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // the moonrise fixture above proves its absence early, this one its presence at depth of night. Scenario { name: "northshire-moon-halo", + map: MAP_AZEROTH, eye: SKY_EYE, look: [-8858.0, -38.0, 358.0], // eye + 300·(elev 55°, az 45°) — the moon's spot at 00:00 minute: 0, @@ -298,6 +387,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // load is resident here; Northshire scenes never exercise that scale. Scenario { name: "stormwind", + map: MAP_AZEROTH, eye: [-8833.38, 628.63, 96.0], look: [-8809.1, 672.3, 94.0], minute: 720, @@ -309,6 +399,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // director look pass, so "looks like shit" gets caught in-loop, not on the director's screen. Scenario { name: "ui-merchant", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -316,6 +407,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "ui-gossip", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -323,6 +415,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "ui-bank", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -330,6 +423,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "ui-quest", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -337,6 +431,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "ui-questgreeting", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -344,6 +439,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "ui-questlog", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -351,6 +447,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "ui-loot", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -358,6 +455,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ }, Scenario { name: "ui-bag", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -367,6 +465,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-tooltip`. Scenario { name: "ui-tooltip", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -376,6 +475,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // seeded hostile wolf. Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-tooltip-world`. Scenario { name: "ui-tooltip-world", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -385,6 +485,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-char`. Scenario { name: "ui-char", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -395,6 +496,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-unitframes`. Scenario { name: "ui-unitframes", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -407,6 +509,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-actionbar`. Scenario { name: "ui-actionbar", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -418,6 +521,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // `WOW_CAPTURE=vplates` (main.rs sizes this window 1024×768 — the 1:1 gx window). Scenario { name: "vplates", + map: MAP_AZEROTH, eye: [-8956.5, -137.5, 85.6], look: [-8949.95, -132.49, 84.8], minute: 720, @@ -428,6 +532,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-worldmap`. Scenario { name: "ui-worldmap", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -436,6 +541,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // The spellbook over a seeded mage book. Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-spellbook`. Scenario { name: "ui-spellbook", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -445,6 +551,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-chatedit`. Scenario { name: "ui-chatedit", + map: MAP_AZEROTH, eye: GROUND_EYE, look: GROUND_LOOK, minute: 720, @@ -455,6 +562,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ // out in the water. Run with `WOW_CAPTURE=name-water`. Scenario { name: "name-water", + map: MAP_AZEROTH, eye: WATER_EYE, look: WATER_LOOK, minute: 720, diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index c47a9765..99434f67 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -212,12 +212,12 @@ use world_map::WorldMapPlugin; /// logs in — so the player sits in the middle of the block instead of Stormwind's edge. const SPAWN_XY: (f32, f32) = (-8949.95, -132.49); -fn main() { +fn main() -> AppExit { // `WOW_CAPTURE=list` just prints the harness scenario names (the source of truth `scripts/visual.sh` // reads) and exits before any window/asset setup. if std::env::var("WOW_CAPTURE").as_deref() == Ok("list") { capture::print_scenario_names(); - return; + return AppExit::Success; } let mut app = App::new(); @@ -779,5 +779,8 @@ fn main() { app.add_plugins(perf::FpsJournalPlugin); } - app.run(); + // Return the app's own exit status instead of dropping it: a failed capture writes + // `AppExit::error()` (see `capture::drive_capture`), and discarding it made the process exit 0 + // with no PNG on disk — which is how a sweep carried on around a missing shot (decision 0743). + app.run() } diff --git a/crates/benilla/src/world_map.rs b/crates/benilla/src/world_map.rs index 3131ba1a..8d3cadf3 100644 --- a/crates/benilla/src/world_map.rs +++ b/crates/benilla/src/world_map.rs @@ -93,6 +93,10 @@ fn load_world_map(mut commands: Commands, world_assets: Option> // `player::wire_in` overwrites this from the server's world-verify the moment it says // where we are. Without it every headless capture is stuck in Eastern Kingdoms, and a // horizon report from anywhere else cannot be reproduced. + // + // A named capture scenario carries its own map (`capture::scenarios::Scenario::map`) + // and writes it here before this runs, so the golden sweep's Kalimdor shots need no env + // at all — see `capture::CapturePlugin::build` (decision 0743). let map = std::env::var("WOW_MAP") .ok() .and_then(|v| v.parse().ok())