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:
@@ -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| {
|
||||
|
||||
@@ -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(), |_| {}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.**
|
||||
|
||||
Reference in New Issue
Block a user