From e62624b11aabc581e46679c2af7808f4c5ce8e3d Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Aug 2026 19:06:09 +0700 Subject: [PATCH] the client boots with no install, and the idle egui lane goes quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - with no WoW install the client boots to the login screen instead of panicking on frame one: three world systems demanded install-gated resources their own startup never inserts - `WOW_DATA=` — set and empty — means there is no install, in any build; the cursor says "no client data" once instead of thirty-one times - the per-frame `bevy_egui pass output has not been prepared` ERROR is gone: a gated context runs an empty pass itself, so the idle lane keeps its savings and the log stays silent - ECS diagnostics name their system in a dev build — no more `` in a panic Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/cursor.rs | 8 ++++ crates/benilla-app/src/debug_panel/mod.rs | 44 +++++++++++++++++-- crates/benilla-app/src/net.rs | 9 +++- crates/benilla-formats/src/install.rs | 27 +++++++++++- crates/benilla-formats/tests/wow_data_none.rs | 27 ++++++++++++ crates/benilla-world/Cargo.toml | 9 +++- crates/benilla-world/src/assets/mod.rs | 19 +++++--- crates/benilla-world/src/clutter.rs | 11 ++++- crates/benilla-world/src/wdl.rs | 15 +++++-- crates/benilla-world/src/worldview.rs | 9 ++++ 10 files changed, 158 insertions(+), 20 deletions(-) create mode 100644 crates/benilla-formats/tests/wow_data_none.rs diff --git a/crates/benilla-app/src/cursor.rs b/crates/benilla-app/src/cursor.rs index aca20555..70a7d346 100644 --- a/crates/benilla-app/src/cursor.rs +++ b/crates/benilla-app/src/cursor.rs @@ -457,6 +457,14 @@ mod macos { // Inserted unconditionally (ahead of the native-cursor early-return below) — `drive` reads // it every frame regardless of whether any MODE cursor decoded. world.insert_non_send_resource(PayloadCursors(HashMap::new())); + // No client data at all → say it ONCE. The per-stem warning below means *this cursor is + // missing from an install that exists*, which is worth a line each; with no install it is + // 31 lines of the same fact, and they were the loudest thing in the log of the frame-one + // crash that decision 1451 is about. + if world.get_resource::().is_none() { + warn!("no client data — the OS cursor stands in for every mode cursor"); + return; + } let mut cursors = HashMap::new(); for stem in CURSOR_STEMS { let Some((w, h, rgba)) = world.get_resource_mut::().and_then(|wa| { diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs index 34011f62..4846d35f 100644 --- a/crates/benilla-app/src/debug_panel/mod.rs +++ b/crates/benilla-app/src/debug_panel/mod.rs @@ -32,8 +32,9 @@ use bevy::camera::CameraOutputMode; use bevy::prelude::*; use bevy::render::render_resource::BlendState; use bevy_egui::{ - egui, EguiContextSettings, EguiContexts, EguiGlobalSettings, EguiInput, EguiPlugin, - EguiPreUpdateSet, EguiPrimaryContextPass, PrimaryEguiContext, + egui, EguiContext, EguiContextSettings, EguiContexts, EguiFullOutput, EguiGlobalSettings, + EguiInput, EguiPlugin, EguiPostUpdateSet, EguiPreUpdateSet, EguiPrimaryContextPass, + PrimaryEguiContext, }; use benilla_world::lighting::{ClockSource, GameClock, WowLighting}; @@ -217,7 +218,44 @@ impl Plugin for DebugPanelPlugin { // No ordering against the UI keyboard feed: the toggle is a dev chord (1043), which no // focused EditBox can consume and which needs no capture gate — the same reason `perf`'s // and `sound`'s toggles never needed one. - .add_systems(Update, (toggle_panel, gate_egui_lane).chain()); + .add_systems(Update, (toggle_panel, gate_egui_lane).chain()) + // The gate's other half: a gated frame still owes bevy_egui a prepared (empty) pass — + // between the end-pass it skipped and the output consumer that doesn't (1452). + .add_systems( + PostUpdate, + feed_gated_egui_output + .after(EguiPostUpdateSet::EndPass) + .before(EguiPostUpdateSet::ProcessOutput), + ); + } +} + +/// The 1445 gate's missing half (decision 1452). Upstream, `run_manually` means "the app will run +/// the egui pass itself this frame" — never "off": `process_output_system` still `take()`s an +/// output from every context every frame and logs at ERROR when none was prepared (bevy_egui +/// 0.39 and 0.41 alike, `output.rs`). With the lane gated that was one ERROR per frame — a red +/// herring beside any real failure, and gate-fatal severity for a by-design idle state (ERROR +/// means "the client is broken": 1450). +/// +/// So a gated frame *runs the empty pass itself* — `Context::run` with default input and no UI, +/// exactly what the error message prescribes for manual mode. Zero widgets means zero shapes: +/// the consumer tessellates nothing, uploads nothing, and the sleeping camera draws nothing — +/// the lane keeps costing what 1445 bought, and bevy_egui's output contract holds. A context +/// that genuinely ran (gate open, or a true manual runner) has `Some` output by now and is left +/// alone. +/// +/// Not hand-built `FullOutput::default()`: `process_output_system` tessellates whatever it is +/// given, and tessellating a context that never began a pass panics `No fonts loaded` (measured +/// here — fonts only initialize inside a real begin-pass). `run` is the initialization path. +fn feed_gated_egui_output( + mut contexts: Query<(&mut EguiContext, &mut EguiFullOutput, &EguiContextSettings)>, +) { + for (mut ctx, mut full_output, settings) in &mut contexts { + if settings.run_manually && full_output.0.is_none() { + // `get_mut`, not `get`: the immutable getter sits behind bevy_egui's + // `immutable_ctx` feature, off in our build. + full_output.0 = Some(ctx.get_mut().run(egui::RawInput::default(), |_| {})); + } } } diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 5d2767d6..5c6dc4b0 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -41,8 +41,15 @@ use motion::{ mark_swimming_creatures, sample_splines, }; pub(crate) use motion::{ - jump_seed, CreatureSwimming, FacingStep, GroundClamped, RemoteMotion, Spline, SplineStopped, + jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline, SplineStopped, }; +// `GroundClamped`'s only consumer outside `net::motion::spline` is the ground-census probe, and a +// probe is an instrument — a build with the instruments compiled out contains nothing that names +// this re-export, and warned about it every time (decision 1451). The `allow` is "unused in *that* +// build", not dead code; a `cfg` here is not the alternative, because seam knowledge has exactly +// three addresses and this file is not one of them (1179, and its test says so out loud). +#[allow(unused_imports)] +pub(crate) use motion::GroundClamped; /// The net subsystem: spawns the background IO threads and drives the per-frame event drain. pub(crate) struct NetPlugin { diff --git a/crates/benilla-formats/src/install.rs b/crates/benilla-formats/src/install.rs index 328a7af6..cc92210a 100644 --- a/crates/benilla-formats/src/install.rs +++ b/crates/benilla-formats/src/install.rs @@ -20,7 +20,9 @@ //! //! 1. **`$WOW_DATA`** — the explicit override, for a second install or a non-standard layout. Kept //! because the director's rule is about what the client *assumes*, not about removing the -//! escape hatch; the sprawl was 11 reads of it, not the variable itself. +//! escape hatch; the sprawl was 11 reads of it, not the variable itself. **Set and empty** +//! (`WOW_DATA=`) means *there is no install*: the ladder stops there and returns nothing, which +//! is how a machine that has an install can still run the no-install path (decision 1451). //! 2. **`/WoW/Data`** — `#[cfg(feature = "dev")]` only. Computed from **this //! crate's** `CARGO_MANIFEST_DIR`, which lands on the same workspace root whichever crate is //! asking, and is what the repo-root `WoW` symlink points at. Gated because a shipped binary @@ -78,8 +80,17 @@ pub fn candidates() -> Vec { fn candidates_from(override_dir: Option, exe_dir: Option) -> Vec { let mut out = Vec::with_capacity(4); - // 1 · the explicit override. + // 1 · the explicit override — and its EMPTY spelling. `WOW_DATA=` (set, no value) is the + // answer *there is no install*: it returns no candidates at all, so [`wow_data`] is `None` + // even in a dev build with the project folder sitting right there. It exists because the + // no-install boot path — which every player who unzips benilla into the wrong folder takes — + // was unreachable in any build we run on this machine, and rotted until it panicked on frame + // one (decision 1451). `scripts/gates.sh` runs the enforcer under it on every commit; a + // session can see what a player without data sees with `WOW_DATA= cargo play`. if let Some(over) = override_dir { + if over.as_os_str().is_empty() { + return Vec::new(); + } out.push(over); } @@ -193,6 +204,18 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + /// `WOW_DATA=` — set and empty — is *there is no install*, and it outranks every other rung + /// including the dev build's project folder. Without it the no-install path is unreachable on + /// any machine that has an install, which is every machine this is developed on (1451). + #[test] + fn an_empty_override_means_there_is_no_install() { + assert_eq!( + probe(Some(""), Some("/games/benilla")), + Vec::::new(), + "`WOW_DATA=` must leave the ladder with no rungs at all" + ); + } + /// **The falsifier for the whole record, as a unit test.** A player build — `dev` off — must /// not look anywhere inside the source tree that compiled it. If this fails, the project-folder /// candidate came back through feature unification and `strings | grep /Users` will diff --git a/crates/benilla-formats/tests/wow_data_none.rs b/crates/benilla-formats/tests/wow_data_none.rs new file mode 100644 index 00000000..597c8af3 --- /dev/null +++ b/crates/benilla-formats/tests/wow_data_none.rs @@ -0,0 +1,27 @@ +//! `WOW_DATA=` — set and empty — means **there is no install**, through the real env read. +//! +//! The ladder's own logic is unit-tested purely against `candidates_from`; this covers the wiring, +//! which is the half a pure test cannot see. It is its own file for the reason the sibling +//! `wow_data_env.rs` explains: setting `$WOW_DATA` is process-global, every test in the workspace +//! now resolves its install through that one read, and cargo gives each integration-test file its +//! own process — so one mutating test per file can only reach itself. +//! +//! What it protects (decision 1451): a dev build on a machine with an install cannot otherwise +//! reach the no-install boot path, so nothing exercised it and it rotted into a frame-one panic. +//! This spelling is what `scripts/gates.sh` runs the engine enforcer under. + +#[test] +fn an_empty_override_means_no_install() { + std::env::set_var("WOW_DATA", ""); + assert_eq!( + benilla_formats::wow_data(), + None, + "`WOW_DATA=` must resolve to no install even where one exists" + ); + assert!( + benilla_formats::candidates().is_empty(), + "`WOW_DATA=` must leave nothing on the ladder to report as looked-in" + ); + + std::env::remove_var("WOW_DATA"); +} diff --git a/crates/benilla-world/Cargo.toml b/crates/benilla-world/Cargo.toml index 00337082..048de7d5 100644 --- a/crates/benilla-world/Cargo.toml +++ b/crates/benilla-world/Cargo.toml @@ -11,7 +11,14 @@ description = "The world renderer: terrain, water, sky, weather, models, lightin # without this a `--no-default-features` player build gets the feature back through unification and # the binary carries the build machine's source-tree path after all. default = ["dev"] -dev = ["benilla-formats/dev", "benilla-assets/dev"] +# +# `bevy/debug` rides with it (decision 1451): without it every ECS diagnostic — a panicking system, +# a parameter that will not validate, the enforcer's whole fault list — reads ``, which is what the frame-one crash it fixes was reported as. It stores +# `&'static str` names on systems and params; it is diagnostics, not an instrument, so nothing +# depends on it and the player build simply says less. Enabled here rather than in `benilla-app` +# so `benilla-worldview` — the enforcer, whose entire output is these names — carries it too. +dev = ["benilla-formats/dev", "benilla-assets/dev", "bevy/debug"] [dependencies] avian3d = "0.6" diff --git a/crates/benilla-world/src/assets/mod.rs b/crates/benilla-world/src/assets/mod.rs index 1b4ef2b7..47f553fe 100644 --- a/crates/benilla-world/src/assets/mod.rs +++ b/crates/benilla-world/src/assets/mod.rs @@ -61,6 +61,17 @@ fn scope_world_art(mut scope: ArtScope, assets: Option>) { /// can't be found or opened, `WorldAssets` is simply absent and downstream startup falls back to /// an empty free-fly scene. fn open_world_assets(mut commands: Commands, device: Res) { + // The one shared global-light buffer, created here (RenderDevice is live by Startup) so it exists + // before any material is built. Inserted FIRST — ahead of the install lookup, so no early return + // below can skip it: it is cloned into `WorldAssets` (for model materials) and read as the + // `SharedLightBuffer` resource by the terrain streamer, the model/particle lanes and the + // render-world upload. Always present — even with no client data — so the render upload has a + // target; harmless if unused. It used to be created *after* the lookup, so a client that found no + // install had no buffer at all and `particles::model::update_model_particles` — a hard + // `Res` — could not validate (decision 1451). + let shared_light = crate::lighting::new_shared_light_buffer(&device); + let light_buf = shared_light.0.clone(); + commands.insert_resource(shared_light); let Some(data) = benilla_formats::wow_data() else { warn!( "no WoW install found — looked in {:?}; starting with no world", @@ -93,14 +104,8 @@ fn open_world_assets(mut commands: Commands, device: Res) { unload_budget, }); - // The one shared global-light buffer, created here (RenderDevice is live by Startup) so it exists - // before any material is built. Cloned into `WorldAssets` (for model materials) and inserted as the - // `SharedLightBuffer` resource (the terrain streamer + the render-world upload read it). Always - // inserted — even with no client data — so the render upload has a target; harmless if unused. - let shared_light = crate::lighting::new_shared_light_buffer(&device); match open_chain(&data) { - Ok(chain) => commands.insert_resource(WorldAssets::open(chain, shared_light.0.clone())), + Ok(chain) => commands.insert_resource(WorldAssets::open(chain, light_buf)), Err(e) => error!("failed to open client data: {e:#}"), } - commands.insert_resource(shared_light); } diff --git a/crates/benilla-world/src/clutter.rs b/crates/benilla-world/src/clutter.rs index a9c63256..21f87402 100644 --- a/crates/benilla-world/src/clutter.rs +++ b/crates/benilla-world/src/clutter.rs @@ -435,12 +435,19 @@ pub(crate) fn stream_chunk_clutter( cam: Query<&GlobalTransform, With>, mut chunks: Query<(Entity, &mut ClutterChunk)>, cfg: Res, - mut geometry: ResMut, - mut assets: ResMut, + geometry: Option>, + assets: Option>, mut meshes: ResMut>, mut images: ResMut>, mut materials: ResMut>, ) { + // Both come from `setup_clutter`, which inserts neither when there is no client data — and a + // hard `ResMut` here is a *validation* failure, not a `None` the body can handle: the system + // never runs, Bevy's default error handler panics, and a client that found no install died on + // its first frame instead of sitting at the login screen (decision 1451). + let (Some(mut geometry), Some(mut assets)) = (geometry, assets) else { + return; + }; let Some(cam_pos) = cam.iter().next().map(|t| t.translation()) else { return; }; diff --git a/crates/benilla-world/src/wdl.rs b/crates/benilla-world/src/wdl.rs index 74f036ae..291faa0d 100644 --- a/crates/benilla-world/src/wdl.rs +++ b/crates/benilla-world/src/wdl.rs @@ -133,14 +133,21 @@ fn setup_wdl( fn stream_wdl( mut commands: Commands, streamer: Option>, - assets: ResMut, + assets: Option>, focus: Res, camera: Query<&Transform, With>, - current_map: Res, - map_catalog: Res, + current_map: Option>, + map_catalog: Option>, mut meshes: ResMut>, ) { - let Some(mut streamer) = streamer else { + // All four are absent together — there is no client data, so `setup_wdl` and `load_world_map` + // both bailed. `Option` rather than a hard `Res` because a missing resource is a *validation* + // failure, not a `None`: the system would never run, and Bevy's default handler panics the + // client (decision 1451). This one is gated on `world_is_live`, so it takes a world with no + // install to reach — which a player build in the wrong folder can still do. + let (Some(mut streamer), Some(assets), Some(current_map), Some(map_catalog)) = + (streamer, assets, current_map, map_catalog) + else { return; }; // The map the *focus* is on — `CurrentMap` once the snap has landed, the picked character's diff --git a/crates/benilla-world/src/worldview.rs b/crates/benilla-world/src/worldview.rs index 0ee7daa4..16484712 100644 --- a/crates/benilla-world/src/worldview.rs +++ b/crates/benilla-world/src/worldview.rs @@ -212,6 +212,15 @@ fn report_check(exit: AppExit) -> AppExit { fact is parked on the game side. Decision 1160.", faults.len() ); + // The no-install run (`WOW_DATA=`, decision 1451) fails for a different reason than 1160's, + // and the run that trips it is a gate line nobody is watching — so it says which reason. + if benilla_formats::wow_data().is_none() { + println!( + "WORLDVIEW_CHECK ran with NO INSTALL: a fault here is a system taking a resource that \ + only exists when there is client data as a hard `Res`/`ResMut`. Take it as `Option` \ + and return, or insert it ahead of the no-data bail. Decision 1451." + ); + } AppExit::error() } /// **What the engine still needs told, and nothing more.**