the client boots with no install, and the idle egui lane goes quiet

- 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 `<Enable the debug feature to see the name>` in a panic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 19:06:09 +07:00
co-authored by Claude Fable 5
parent 00c3a49a53
commit e62624b11a
10 changed files with 158 additions and 20 deletions
+8
View File
@@ -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::<WorldAssets>().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::<WorldAssets>().and_then(|wa| {
+41 -3
View File
@@ -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(), |_| {}));
}
}
}
+8 -1
View File
@@ -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 {
+25 -2
View File
@@ -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. **`<project folder>/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<PathBuf> {
fn candidates_from(override_dir: Option<PathBuf>, exe_dir: Option<PathBuf>) -> Vec<PathBuf> {
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::<PathBuf>::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 <binary> | grep /Users` will
@@ -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");
}
+8 -1
View File
@@ -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 `<Enable the debug
# feature to see the name>`, 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"
+12 -7
View File
@@ -61,6 +61,17 @@ fn scope_world_art(mut scope: ArtScope, assets: Option<ResMut<WorldAssets>>) {
/// 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<RenderDevice>) {
// 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<SharedLightBuffer>` — 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<RenderDevice>) {
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);
}
+9 -2
View File
@@ -435,12 +435,19 @@ pub(crate) fn stream_chunk_clutter(
cam: Query<&GlobalTransform, With<WorldCamera>>,
mut chunks: Query<(Entity, &mut ClutterChunk)>,
cfg: Res<ClutterConfig>,
mut geometry: ResMut<ClutterGeometry>,
mut assets: ResMut<WorldAssets>,
geometry: Option<ResMut<ClutterGeometry>>,
assets: Option<ResMut<WorldAssets>>,
mut meshes: ResMut<Assets<Mesh>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
) {
// 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;
};
+11 -4
View File
@@ -133,14 +133,21 @@ fn setup_wdl(
fn stream_wdl(
mut commands: Commands,
streamer: Option<ResMut<WdlStreamer>>,
assets: ResMut<WorldAssets>,
assets: Option<ResMut<WorldAssets>>,
focus: Res<crate::terrain_stream::ViewFocus>,
camera: Query<&Transform, With<WorldCamera>>,
current_map: Res<CurrentMap>,
map_catalog: Res<MapCatalogRes>,
current_map: Option<Res<CurrentMap>>,
map_catalog: Option<Res<MapCatalogRes>>,
mut meshes: ResMut<Assets<Mesh>>,
) {
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
+9
View File
@@ -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.**