From edec5b2207882860e2fd4f1a67fbcc6b001c382b Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 17 Sep 2026 16:47:09 +0700 Subject: [PATCH] settings live in one registry, and the net drain starts to peel - the CVars are one engine-side table that outlives every interface reload: a change fires its observer, the eight latched rows wait for RestartGx, and /console gains set, cvar_reset, cvar_default, cvarlist and help (2303) - every push into the interface runs in one feed phase after the net drain and before the tick, held by a structural test on the built schedule (2304) - subsystems own their packet handlers, dispatched in packet order; the auction house is the first family out of the 273-arm drain (2305) Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 23 + crates/benilla-app/src/bindings.rs | 5 +- crates/benilla-app/src/capture/mod.rs | 3 +- crates/benilla-app/src/char_select/addons.rs | 61 +- crates/benilla-app/src/char_select/mod.rs | 164 +- crates/benilla-app/src/chat_bubble.rs | 10 + crates/benilla-app/src/combat_text/mod.rs | 11 + crates/benilla-app/src/console.rs | 412 +++ crates/benilla-app/src/cursor.rs | 16 +- crates/benilla-app/src/cvars.rs | 2787 +++++++++-------- crates/benilla-app/src/death.rs | 4 +- crates/benilla-app/src/game_plugins.rs | 175 +- crates/benilla-app/src/game_tip.rs | 30 +- crates/benilla-app/src/lib.rs | 1 + crates/benilla-app/src/login/mod.rs | 8 +- crates/benilla-app/src/minimap/mod.rs | 37 +- crates/benilla-app/src/nameplates.rs | 12 + crates/benilla-app/src/net.rs | 2 + crates/benilla-app/src/net/apply.rs | 74 +- crates/benilla-app/src/net/apply/params.rs | 3 - crates/benilla-app/src/net/apply/session.rs | 5 - crates/benilla-app/src/net/handlers.rs | 329 ++ crates/benilla-app/src/perf/journal.rs | 10 + crates/benilla-app/src/perf/mod.rs | 4 +- crates/benilla-app/src/player.rs | 2 + crates/benilla-app/src/player/camera.rs | 56 +- .../benilla-app/src/player/camera_dynamics.rs | 24 + crates/benilla-app/src/player/camera_view.rs | 71 +- crates/benilla-app/src/portrait/mod.rs | 8 + crates/benilla-app/src/realm_select/mod.rs | 8 +- crates/benilla-app/src/realmlist.rs | 13 + crates/benilla-app/src/screenshot.rs | 4 +- crates/benilla-app/src/sound/mod.rs | 26 + crates/benilla-app/src/target/mod.rs | 14 + crates/benilla-app/src/text_filter.rs | 11 + crates/benilla-app/src/tutorial.rs | 4 +- crates/benilla-app/src/ui_action/mod.rs | 17 +- crates/benilla-app/src/ui_auction/mod.rs | 10 +- .../apply/auction.rs => ui_auction/net.rs} | 165 +- crates/benilla-app/src/ui_aura.rs | 8 +- crates/benilla-app/src/ui_bank.rs | 4 +- crates/benilla-app/src/ui_battlefield.rs | 8 +- .../src/ui_battlefield_positions.rs | 4 +- .../benilla-app/src/ui_battlefield_score.rs | 4 +- crates/benilla-app/src/ui_binder.rs | 4 +- crates/benilla-app/src/ui_cast.rs | 6 +- crates/benilla-app/src/ui_char.rs | 8 +- crates/benilla-app/src/ui_chat.rs | 27 +- crates/benilla-app/src/ui_chat/away.rs | 4 +- crates/benilla-app/src/ui_chat/combat.rs | 25 + crates/benilla-app/src/ui_chat/input/mod.rs | 80 +- crates/benilla-app/src/ui_chat/input/parse.rs | 47 +- crates/benilla-app/src/ui_chat/logging.rs | 6 +- crates/benilla-app/src/ui_chat/settings.rs | 9 +- crates/benilla-app/src/ui_chat/tests.rs | 41 - crates/benilla-app/src/ui_craft.rs | 5 +- crates/benilla-app/src/ui_dialog_verbs.rs | 7 +- crates/benilla-app/src/ui_dressup.rs | 6 +- crates/benilla-app/src/ui_duel.rs | 4 +- crates/benilla-app/src/ui_follow.rs | 4 +- crates/benilla-app/src/ui_gamma.rs | 10 + crates/benilla-app/src/ui_gm_ticket.rs | 4 +- crates/benilla-app/src/ui_gossip.rs | 4 +- crates/benilla-app/src/ui_guild/mod.rs | 13 +- crates/benilla-app/src/ui_honor.rs | 4 +- crates/benilla-app/src/ui_inspect.rs | 4 +- crates/benilla-app/src/ui_instance.rs | 4 +- crates/benilla-app/src/ui_item_text.rs | 4 +- crates/benilla-app/src/ui_items/mod.rs | 11 +- crates/benilla-app/src/ui_layout.rs | 12 +- crates/benilla-app/src/ui_logout.rs | 4 +- crates/benilla-app/src/ui_loot.rs | 17 +- crates/benilla-app/src/ui_loot_roll.rs | 4 +- crates/benilla-app/src/ui_macro/mod.rs | 15 +- crates/benilla-app/src/ui_mail/mod.rs | 4 +- crates/benilla-app/src/ui_merchant.rs | 6 +- crates/benilla-app/src/ui_mirror.rs | 3 +- crates/benilla-app/src/ui_models/mod.rs | 2 +- crates/benilla-app/src/ui_net.rs | 4 +- crates/benilla-app/src/ui_party/mod.rs | 4 +- crates/benilla-app/src/ui_pet/mod.rs | 8 +- crates/benilla-app/src/ui_pet_book.rs | 3 +- crates/benilla-app/src/ui_pet_doll.rs | 5 +- crates/benilla-app/src/ui_pet_stats.rs | 9 +- crates/benilla-app/src/ui_petition/mod.rs | 4 +- crates/benilla-app/src/ui_quest.rs | 4 +- crates/benilla-app/src/ui_quest_log.rs | 6 +- crates/benilla-app/src/ui_quest_share.rs | 4 +- crates/benilla-app/src/ui_reputation.rs | 4 +- crates/benilla-app/src/ui_script/lifecycle.rs | 33 +- crates/benilla-app/src/ui_script/mod.rs | 74 +- crates/benilla-app/src/ui_shapeshift.rs | 1 - crates/benilla-app/src/ui_social/mod.rs | 4 +- crates/benilla-app/src/ui_spellbook.rs | 3 +- crates/benilla-app/src/ui_stable.rs | 4 +- crates/benilla-app/src/ui_summon.rs | 4 +- crates/benilla-app/src/ui_tabard.rs | 4 +- crates/benilla-app/src/ui_talent.rs | 2 +- crates/benilla-app/src/ui_talent_wipe.rs | 4 +- crates/benilla-app/src/ui_taxi/mod.rs | 4 +- crates/benilla-app/src/ui_tooltip/mod.rs | 5 +- crates/benilla-app/src/ui_trade.rs | 12 +- crates/benilla-app/src/ui_tradeskill.rs | 2 +- crates/benilla-app/src/ui_trainer/mod.rs | 4 +- crates/benilla-app/src/ui_unit.rs | 34 +- crates/benilla-app/src/video.rs | 198 +- crates/benilla-app/src/vplates.rs | 72 +- crates/benilla-app/src/world_backdrop.rs | 12 + crates/benilla-protocol/Cargo.toml | 3 + crates/benilla-protocol/src/events.rs | 16 +- crates/benilla-protocol/src/lib.rs | 2 +- crates/benilla-ui/src/script/chat_misc.rs | 22 +- crates/benilla-ui/src/script/cvars.rs | 264 +- crates/benilla-ui/src/script/mod.rs | 2 +- crates/benilla-ui/src/script/model.rs | 4 + crates/benilla-world/src/clutter.rs | 6 +- crates/benilla-world/src/terrain_stream.rs | 6 +- 117 files changed, 3844 insertions(+), 2067 deletions(-) create mode 100644 crates/benilla-app/src/console.rs create mode 100644 crates/benilla-app/src/net/handlers.rs rename crates/benilla-app/src/{net/apply/auction.rs => ui_auction/net.rs} (70%) diff --git a/Cargo.lock b/Cargo.lock index 057db491..f775f249 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -624,6 +624,7 @@ dependencies = [ "num-bigint", "rand 0.8.6", "sha1", + "strum", ] [[package]] @@ -6138,6 +6139,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "svg_fmt" version = "0.4.5" diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index a517596d..6379fd1e 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -221,9 +221,10 @@ impl Plugin for BindingsPlugin { .chain() .in_set(crate::ui_script::UiInput) .in_set(BindingSet) - .before(benilla_world::schedule::WorldStage::Input) .in_set(InWorldGated), - drain_binding_requests, + // After the tick: the requests are Lua's own (`SaveBindings`, `RunBinding`), + // queued by handlers the tick dispatched, and a save must not wait a frame. + drain_binding_requests.after(crate::ui_script::UiInput), ), ); } diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 664d6c1a..891a1d8b 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -996,7 +996,8 @@ fn hold_clock(mut clock: ResMut>) { /// The three scene-population queries the `FPS_PROBE` line prints — bundled because they are one /// concern (how much world is resident, and how much of it survived the cull) and because -/// `drive_capture` sits against Bevy's 16-parameter ceiling, which `cvars::KnobParams` hit first. +/// `drive_capture` sits against Bevy's 16-parameter ceiling, which the CVar host's old knob +/// bundle hit first (retired by 2303). #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct ProbeCensus<'w, 's> { particles: Query<'w, 's, &'static benilla_world::particles::ParticleEmitter>, diff --git a/crates/benilla-app/src/char_select/addons.rs b/crates/benilla-app/src/char_select/addons.rs index 74f8c561..5038798a 100644 --- a/crates/benilla-app/src/char_select/addons.rs +++ b/crates/benilla-app/src/char_select/addons.rs @@ -516,13 +516,13 @@ fn status_label<'a>(strings: &'a GlueStrings, token: &'a str) -> &'a str { } /// Flip the *Load out of date AddOns* box: the `checkAddonVersion` CVar **inverted** (1292 §2, -/// byte-verified — ticked = `"0"`). Written through [`UiScript::set_cvar_engine`] so it rides the -/// change queue like a Lua `SetCVar` and the host's sync persists it (the minimap-zoom pattern) — -/// nothing else to do: the statuses repaint from [`drive_addons_panel`]'s per-frame mirror, no -/// rescan, because the gate re-reads the flag per query (1292 §2.2). -fn toggle_force_load(script: &mut UiScript) { - let checking = script.cvar("checkAddonVersion").is_none_or(|v| v != "0"); - script.set_cvar_engine("checkAddonVersion", if checking { "0" } else { "1" }); +/// byte-verified — ticked = `"0"`). A host write into the registry (2303), which is what +/// persists it and what the VM's mirror learns — nothing else to do: the statuses repaint from +/// [`drive_addons_panel`]'s per-frame mirror, no rescan, because the gate re-reads the flag per +/// query (1292 §2.2). +fn toggle_force_load(cvars: &mut crate::cvars::Cvars) { + let checking = cvars.addon_version_check(); + cvars.set("checkAddonVersion", if checking { "0" } else { "1" }); } /// The panel's clickable parts. @@ -578,7 +578,8 @@ pub(super) fn drive_addons_panel( art: Res, assets: Res, strings: Option>, - mut script: Option>, + script: Option>, + mut cvars: ResMut, keys: Res>, mouse: Res>, mut wheel: MessageReader, @@ -641,9 +642,7 @@ pub(super) fn drive_addons_panel( AddonsAction::ForceLoad => { // No VM (a bare test world / a capture): nothing to write to and nothing the // walk would read differently — the box is inert, honestly. - if let Some(script) = script.as_deref_mut() { - toggle_force_load(script); - } + toggle_force_load(&mut cvars); } } } @@ -2007,28 +2006,36 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } - /// The force-load box IS the `checkAddonVersion` CVar inverted, and a toggle goes through - /// the engine write ([`UiScript::set_cvar_engine`]) so it rides the change queue — that is - /// the whole persistence story (the host's sync drains the queue and dirties the config); - /// the panel itself only repaints from its per-frame mirror, rescanning nothing (1292 §2.2). + /// The force-load box IS the `checkAddonVersion` CVar inverted, and a toggle is a host + /// write into the registry (2303) — that is the whole persistence story (the write dirties + /// the config and reaches the VM's mirror through the outbox); the panel itself only + /// repaints from its per-frame mirror, rescanning nothing (1292 §2.2). #[test] - fn the_force_load_box_flips_the_cvar_through_the_engine_queue() { - let mut script = UiScript::new().unwrap(); - script.register_cvars([("checkAddonVersion", "1")]); + fn the_force_load_box_flips_the_cvar_in_the_registry() { + let mut cvars = crate::cvars::Cvars::default(); + assert!( + cvars.addon_version_check(), + "the registrar default: check ON" + ); - toggle_force_load(&mut script); + toggle_force_load(&mut cvars); assert_eq!( - script.take_cvar_changes(), - vec![("checkAddonVersion".to_string(), "0".to_string())], + cvars.get("checkAddonVersion"), + Some("0"), "tick: check ON (\"1\") flips to \"0\" — box ticked = version gate open" ); - assert_eq!(script.cvar("checkAddonVersion").as_deref(), Some("0")); - - toggle_force_load(&mut script); + assert!(!cvars.addon_version_check()); assert_eq!( - script.take_cvar_changes(), - vec![("checkAddonVersion".to_string(), "1".to_string())], - "untick: back to the registrar default, and the queue carries it again" + cvars.take_events().len(), + 1, + "a host write is an accepted move — the config dirties and the mirror learns it" + ); + + toggle_force_load(&mut cvars); + assert_eq!( + cvars.get("checkAddonVersion"), + Some("1"), + "untick: back to the registrar default" ); } diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index 81d70b91..45210095 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -173,6 +173,11 @@ impl Plugin for CharSelectPlugin { ) .chain() .before(crate::glue::GlueVisuals) + // After the UI tick: one member holds the VM (the addons panel reads + // the manifest through it), and every VM holder in `Update` declares + // its side of the tick (decision 2304). A glue screen has no push the + // tick must see, so the whole chain takes the drain side. + .after(crate::ui_script::UiInput) .run_if(in_state(ClientState::CharSelect)), ) .chain() @@ -407,9 +412,9 @@ fn last_character_row(value: &str) -> Option { /// Out of range falls back to the **first** row, never to the nearest one: a character deleted /// since you last played, or a realm with fewer characters, puts you at the top of the list rather /// than beside where the old row used to be. -fn remembered_row(persist: &crate::cvars::CvarPersist, len: usize) -> usize { - persist - .stored(CVAR_LAST_CHARACTER) +fn remembered_row(cvars: &crate::cvars::Cvars, len: usize) -> usize { + cvars + .get(CVAR_LAST_CHARACTER) .and_then(last_character_row) .filter(|&row| row < len) .unwrap_or(0) @@ -423,42 +428,28 @@ fn remembered_row(persist: &crate::cvars::CvarPersist, len: usize) -> usize { /// and clicks and arrow keys reach `0x472740` without ever going near it. [`Roster::pending_pick`] /// is exactly that moment for us, so [`Roster::pending_index`] is what this reads. /// -/// The write goes through [`UiScript::set_cvar_engine`] so it rides the change queue like a Lua -/// `SetCVar` and the host's sync persists it — the minimap-zoom pattern (1131), already used from -/// this screen by the AddOns panel's force-load box (1293). **One divergence, stated:** the +/// The write is a host write into the registry ([`crate::cvars::Cvars::set`]), which is what +/// persists it and what the VM's mirror learns (2303) — the shape the AddOns panel's force-load +/// box uses from this same screen (1293). **One divergence, stated:** the /// reference flushes `Config.wtf` synchronously in the same call (`0x46b6f6`), while ours reaches /// disk on the exit edge with every other CVar (1528) — so a crash between entering the world and /// quitting loses the memory, where the reference would not. That is the CVar store's shape, not /// this key's, and changing it is an autosave design (1528's own "what this does NOT fix"). fn persist_last_character( roster: Res, - mut script: Option>, - // Memory about the VM's CVar table, so it dies with the VM (decision 1290). A bare `Local` - // here would be betting that `cvars::sync_cvars`'s per-VM seed carries this key into the next - // table — true today, and exactly the "correct against one VM, silently wrong against the - // next" shape 1290 built its structural gate to refuse. `get_for` because this system also - // runs while there is no VM at all. - mut mirrored: Local>>, + mut cvars: ResMut, + // A plain `Local` is honest here since 2303: the registry outlives every VM, so the row + // written once stays written — the per-VM memo this used to need was betting on the seed. + mut mirrored: Local>, ) { let Some(row) = roster.pending_index() else { return; // nobody is entering the world — nothing to remember }; - let mirrored = mirrored.get_for(script.as_deref()); if *mirrored == Some(row) { return; } - let Some(script) = script.as_deref_mut() else { - return; - }; - script.set_cvar_engine(CVAR_LAST_CHARACTER, &last_character_value(row)); - // Latch only once the table actually took it. An engine write to a name the host has not - // registered yet is a deliberate silent no-op (`script::cvars::set_from_engine`), and the - // per-VM seed that registers it runs in `cvars::sync_cvars` — a sibling `Update` system with - // no ordering against this one. Latching on the frame the write was dropped would swallow - // exactly one entry, and it would be the session's first. - if script.cvar(CVAR_LAST_CHARACTER).is_some() { - *mirrored = Some(row); - } + cvars.set(CVAR_LAST_CHARACTER, &last_character_value(row)); + *mirrored = Some(row); } /// Ask the parked IO thread to log in as `guid` (the pick channel) and remember it as pending. @@ -478,11 +469,11 @@ fn apply_roster_policy( mut msgs: MessageReader, mut roster: ResMut, pick: Res, - // The remembered row (decision 1622) — read off the persist state rather than the VM's table - // because it is a value the *file* owns and the session only mirrors, and because reading it + // The remembered row (decision 1622) — read off the registry rather than the VM's table + // because the registry is the store that outlives every VM (2303), and because reading it // here keeps this system send-able. It is consulted exactly once per process: `selected` is // `None` only before the first roster lands. - persist: Res, + cvars: Res, // Is the character pick already spoken for? Present only when a rig is driving this run // (decision 1174's always-present run fact) — absent in every ordinary run, which is the // player answer and the one this screen was written for. @@ -543,7 +534,7 @@ fn apply_roster_policy( // reference reads the CVar in the char-list rebuild itself (`0x4724d0` → `0x472740`), // not once at startup, so the screen always opens on whoever you last entered the // world as. Out of range falls back to the first row; see `remembered_row`. - let row = remembered_row(&persist, roster.chars.len()); + let row = remembered_row(&cvars, roster.chars.len()); // Greppable, and it names the character rather than only the index: "the first row // happened to be right" and "the memory worked" are the same picture on screen, and // this line is the only thing that tells them apart in a log. @@ -1448,8 +1439,8 @@ mod tests { .init_resource::() .insert_resource(CharPick(tx)) .insert_resource(match stored { - Some(v) => crate::cvars::CvarPersist::with_stored(CVAR_LAST_CHARACTER, v), - None => crate::cvars::CvarPersist::default(), + Some(v) => crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, v), + None => crate::cvars::Cvars::default(), }) .add_message::() .add_message::() @@ -1571,10 +1562,7 @@ mod tests { app.add_plugins(MinimalPlugins) .init_resource::() .insert_resource(CharPick(tx)) - .insert_resource(crate::cvars::CvarPersist::with_stored( - CVAR_LAST_CHARACTER, - "0", - )) + .insert_resource(crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, "0")) .add_message::() .add_message::() .add_systems(Update, apply_roster_policy); @@ -1624,14 +1612,11 @@ mod tests { #[test] fn only_entering_the_world_writes_the_cvar() { let (tx, _rx) = crossbeam_channel::unbounded(); - let script = benilla_ui::script::UiScript::new().unwrap(); - script.register_cvars([(CVAR_LAST_CHARACTER, "0")]); - let mut app = App::new(); app.add_plugins(MinimalPlugins) .init_resource::() + .init_resource::() .insert_resource(CharPick(tx)) - .insert_non_send_resource(script) .add_systems(Update, persist_last_character); { let mut roster = app.world_mut().resource_mut::(); @@ -1645,96 +1630,35 @@ mod tests { } app.update(); assert!( - app.world_mut() - .non_send_resource_mut::() - .take_cvar_changes() - .is_empty(), + !app.world().resource::().has_events(), "a selection alone must NOT be remembered — the reference writes nothing here", ); // …and now Enter World, on the row that was selected. app.world_mut().resource_mut::().pending_pick = Some(2); app.update(); + let moved: Vec<(String, String)> = app + .world_mut() + .resource_mut::() + .take_events() + .into_iter() + .map(|e| (e.name, e.new)) + .collect(); assert_eq!( - app.world_mut() - .non_send_resource_mut::() - .take_cvar_changes(), + moved, vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())], - "guid 2 sits at row 1, and the row is what rides the queue", + "guid 2 sits at row 1, and the row is what the registry took", ); - } - - /// **The 1290 property, at this call site.** A login replaces the VM, and the memo of "the - /// table already says 1" must die with it — otherwise the mirror stays quiet against a table - /// that has never been told, and the memory survives only for as long as some *other* module - /// happens to carry the key across (`cvars::sync_cvars`'s saved-base seed does, today). - #[test] - fn a_replaced_vm_is_told_the_remembered_row_again() { - let (tx, _rx) = crossbeam_channel::unbounded(); - let fresh = || { - let s = benilla_ui::script::UiScript::new().unwrap(); - s.register_cvars([(CVAR_LAST_CHARACTER, "0")]); - s - }; - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .init_resource::() - .insert_resource(CharPick(tx)) - .insert_non_send_resource(fresh()) - .add_systems(Update, persist_last_character); - { - let mut roster = app.world_mut().resource_mut::(); - roster.chars = vec![character(1, "Kerwind"), character(2, "Xero")]; - roster.pending_pick = Some(2); - } + // Steady frames stay quiet, and the next VM is seeded from the registry — which is what + // replaced the per-VM memo this system used to carry (1290's property, now held by the + // store outliving the VM, 2303). app.update(); - app.update(); // steady frames stay quiet — the memo does its job within one VM - - // The login edge: `ui_script::lifecycle` drops the VM and installs a boot VM in its place. - app.world_mut() - .insert_non_send_resource::(fresh()); - app.update(); - - assert_eq!( - app.world_mut() - .non_send_resource_mut::() - .take_cvar_changes(), - vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())], - "the new VM's table must be told the row too — a memo that outlived the old one \ - would leave this table on its default and lose the memory at quit", - ); - } - - /// The write must survive the frame in which the host has not registered its table yet: an - /// engine write to an unregistered name is a deliberate silent no-op, so latching on it would - /// swallow the session's FIRST entry — the one launch-to-launch memory exists for. - #[test] - fn an_entry_made_before_the_cvar_table_exists_is_not_lost() { - let (tx, _rx) = crossbeam_channel::unbounded(); - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .init_resource::() - .insert_resource(CharPick(tx)) - .insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap()) - .add_systems(Update, persist_last_character); - { - let mut roster = app.world_mut().resource_mut::(); - roster.chars = vec![character(1, "Kerwind"), character(2, "Xero")]; - roster.pending_pick = Some(2); - } - - app.update(); // the table has no such name yet — the write is dropped - app.world_mut() - .non_send_resource_mut::() - .register_cvars([(CVAR_LAST_CHARACTER, "0")]); - app.update(); // ...and the next frame must still catch it up - - assert_eq!( - app.world_mut() - .non_send_resource_mut::() - .take_cvar_changes(), - vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())], - ); + let cvars = app.world().resource::(); + assert!(!cvars.has_events()); + assert!(cvars + .vm_seed() + .iter() + .any(|r| r.name == CVAR_LAST_CHARACTER && r.value == "1")); } /// The reverse arrival order (result first, roster after) stays armed and is answered by the diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index eb49da76..e7c21bc0 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -812,8 +812,18 @@ pub(crate) struct BubbleSet; pub(crate) struct ChatBubblePlugin; +/// The two bubble switches' change callback (1139, 2303): flags, like every other pair. +pub(crate) fn on_cvar(ev: On, mut bubbles: ResMut) { + match ev.key().as_str() { + "chatbubbles" => bubbles.all = ev.flag(), + "chatbubblesparty" => bubbles.party = ev.flag(), + _ => {} + } +} + impl Plugin for ChatBubblePlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/combat_text/mod.rs b/crates/benilla-app/src/combat_text/mod.rs index 2abb0512..d4cdacb9 100644 --- a/crates/benilla-app/src/combat_text/mod.rs +++ b/crates/benilla-app/src/combat_text/mod.rs @@ -585,10 +585,21 @@ fn melee_impact_text( /// inside the append window the mesh rebuild waits on). pub(crate) struct CombatTextPlugin; +/// The damage-text rows' change callback (decision 2303): three flags. +pub(crate) fn on_cvar(ev: On, mut gates: ResMut) { + match ev.key().as_str() { + "combatdamage" => gates.combat_damage = ev.flag(), + "petmeleedamage" => gates.pet_melee = ev.flag(), + "petspelldamage" => gates.pet_spell = ev.flag(), + _ => {} + } +} + impl Plugin for CombatTextPlugin { fn build(&self, app: &mut App) { // The Update append window (see [`UiQuadAppend`]): after the camera controller, and // projecting through the camera's FRESH Transform (not the stale propagated global). + app.add_observer(on_cvar); app.init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/console.rs b/crates/benilla-app/src/console.rs new file mode 100644 index 00000000..00bb6a44 --- /dev/null +++ b/crates/benilla-app/src/console.rs @@ -0,0 +1,412 @@ +//! The in-game **console command registry** (decision 2303) — the reference's `ConsoleCommand` +//! table, host side: `ConsoleCommandRegister 0x63f9e0` over a `TSExplicitList`, +//! with parse / register / lookup / execute (wow-re `console/console.md`, the upper band). A +//! subsystem registers its commands from its own plugin ([`ConsoleCommandApp::console_command`]), +//! the way each of the reference's registers into the one table, and `/console ` reaches +//! [`execute`] through the chat drain. +//! +//! **A CVar is a command too.** The reference's `CVar::Register` installs a per-CVar command +//! under the CVar's own name (`0x63dde0`): a bare name prints `CVar "%s" is "%s"`, a name with a +//! value sets it. So does this — over [`Cvars`] — after the registered commands, which is the +//! reference's own lookup order. The four commands `ConsoleVar.cpp`'s `Initialize` registers +//! (`set`, `cvar_reset`, `cvar_default`, `cvarlist`) are the built-ins [`ConsolePlugin`] adds, +//! plus `help`, which the reference's registry runtime owns. +//! +//! There is no console screen here — the drop-down that `` ` `` toggles in the reference — so a +//! command's output is a system line in the chat frame. That is the seam `/console` already +//! used; the registry is what the arm match behind it became. + +use std::collections::BTreeMap; + +use bevy::prelude::*; + +use crate::cvars::{Cvars, SetOutcome}; + +/// A command's body: the whole world, the argument tail after the command name (trimmed), and +/// the lines to print in return. +pub(crate) type ConsoleHandler = fn(&mut World, &str) -> Vec; + +#[derive(Clone, Copy)] +struct ConsoleCommand { + /// The registered spelling, as `help` prints it. + name: &'static str, + help: &'static str, + run: ConsoleHandler, +} + +/// The registry — lowercased name → command, in name order. +#[derive(Resource, Default)] +pub(crate) struct ConsoleCommands { + by_key: BTreeMap, +} + +/// `app.console_command(name, help, handler)` — a subsystem registering its own command from +/// its plugin, the reference's `ConsoleCommandRegister`. +pub(crate) trait ConsoleCommandApp { + fn console_command( + &mut self, + name: &'static str, + help: &'static str, + run: ConsoleHandler, + ) -> &mut Self; +} + +impl ConsoleCommandApp for App { + fn console_command( + &mut self, + name: &'static str, + help: &'static str, + run: ConsoleHandler, + ) -> &mut Self { + let mut table = self.world_mut().get_resource_or_init::(); + let key = name.to_ascii_lowercase(); + // Two registrations of one name is a wiring bug, never a runtime condition — and the + // reference's registry would silently shadow one with the other. + assert!( + !table.by_key.contains_key(&key), + "console command {name:?} registered twice" + ); + table.by_key.insert(key, ConsoleCommand { name, help, run }); + self + } +} + +/// The built-ins: the reference's four `ConsoleVar.cpp` commands and `help`. +pub(crate) struct ConsolePlugin; + +impl Plugin for ConsolePlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .console_command("set", "Set the value of a CVar.", set) + .console_command("cvar_reset", "Reset a CVar to its default.", cvar_default) + .console_command("cvar_default", "Reset a CVar to its default.", cvar_default) + .console_command( + "cvarlist", + "List the CVars, optionally matching a string.", + cvarlist, + ) + .console_command("help", "List the console commands, or describe one.", help); + } +} + +/// Run one console line — the reference's `ConsoleCommandExecute`: the first token is a command +/// name (case-insensitive), the rest is its argument tail. A registered command runs; else a +/// registered CVar answers as its per-CVar command does; else the line is reported as unknown. +/// Returns the lines to print. +/// +/// A CVar write here is applied **at once** — its observers run before this returns — which is +/// the reference's shape (the callback runs inside `CVar::Set`), and what makes a typed +/// `/console farclip 500` visible on the next frame rather than the one after. +pub(crate) fn execute(world: &mut World, line: &str) -> Vec { + let line = line.trim(); + let (name, args) = line + .split_once(char::is_whitespace) + .map_or((line, ""), |(n, a)| (n, a.trim())); + if name.is_empty() { + return vec!["console: no command given (`help` lists the commands)".to_string()]; + } + let command = world + .get_resource::() + .and_then(|t| t.by_key.get(&name.to_ascii_lowercase()).copied()); + if let Some(command) = command { + return (command.run)(world, args); + } + if world + .get_resource::() + .is_some_and(|c| c.row(name).is_some()) + { + return if args.is_empty() { + vec![print_cvar(world, name)] + } else { + set_cvar(world, name, args) + }; + } + vec![format!( + "console: '{name}' is not a command or a CVar (`help` lists the commands)" + )] +} + +/// `CVar "%s" is "%s"` — the per-CVar command's empty-argument arm (`0x63dde0`), with the +/// staged value beside it when one is waiting for the latch boundary. +fn print_cvar(world: &World, name: &str) -> String { + let cvars = world.resource::(); + let Some(row) = cvars.row(name) else { + return format!("console: no CVar named '{name}'"); + }; + match &row.pending { + Some(staged) => format!( + "CVar \"{}\" is \"{}\" (\"{staged}\" staged until the next RestartGx)", + row.name, row.value + ), + None => format!("CVar \"{}\" is \"{}\"", row.name, row.value), + } +} + +/// A write through the registry, its observers run, and the outcome said back. +fn set_cvar(world: &mut World, name: &str, value: &str) -> Vec { + let (outcome, events) = { + let mut cvars = world.resource_mut::(); + let outcome = cvars.set(name, value); + (outcome, cvars.take_events()) + }; + for event in events { + world.trigger(event); + } + match outcome { + SetOutcome::Unknown => vec![format!("console: no CVar named '{name}'")], + SetOutcome::Refused => vec![format!( + "console: '{value}' is not a number, and {name} takes one" + )], + SetOutcome::Unchanged | SetOutcome::Changed => Vec::new(), + SetOutcome::Staged => vec![format!( + "{name} staged as \"{value}\" — applies at the next RestartGx" + )], + } +} + +/// `set ` — the reference's `0x63d500`. **One stated divergence:** the reference +/// registers an unknown name as a new category-5 record, invisible to Lua; ours refuses it, +/// because a row nothing reads is exactly what the registry's honest-tree rule exists to keep +/// out, and `config.toml` preserves an unknown key on its own. +fn set(world: &mut World, args: &str) -> Vec { + let (name, value) = args + .split_once(char::is_whitespace) + .map_or((args, ""), |(n, v)| (n, v.trim())); + if name.is_empty() || value.is_empty() { + return vec!["usage: set ".to_string()]; + } + set_cvar(world, name, value) +} + +/// `cvar_reset ` / `cvar_default ` — the reference's `0x63d590`/`0x63d640`. One +/// body for both: our rows carry no separate reset value, so the reset target is the default. +fn cvar_default(world: &mut World, args: &str) -> Vec { + let name = args.split_whitespace().next().unwrap_or(""); + if name.is_empty() { + return vec!["usage: cvar_default ".to_string()]; + } + let default = world + .resource::() + .default_of(name) + .map(str::to_string); + let Some(default) = default else { + return vec![format!("console: no CVar named '{name}'")]; + }; + set_cvar(world, name, &default) +} + +/// `cvarlist [match]` — the reference's `0x63d6f0`, in a format of our own: one line per row, +/// the value, the default when the value moved, the staged value when one is waiting, and +/// whether the session owns the row or an addon declared it. +fn cvarlist(world: &mut World, args: &str) -> Vec { + let wanted = args.split_whitespace().next().map(str::to_ascii_lowercase); + let cvars = world.resource::(); + let mut rows: Vec<_> = cvars + .rows() + .filter(|r| { + wanted + .as_deref() + .is_none_or(|w| r.name.to_ascii_lowercase().contains(w)) + }) + .collect(); + rows.sort_by_key(|r| r.name.to_ascii_lowercase()); + let mut out: Vec = rows + .iter() + .map(|r| { + let mut line = format!("{} = \"{}\"", r.name, r.value); + if r.value != r.default { + line.push_str(&format!(" (default \"{}\")", r.default)); + } + if let Some(staged) = &r.pending { + line.push_str(&format!(" [staged \"{staged}\"]")); + } + if cvars.is_session_owned(&r.name) { + line.push_str(" [session]"); + } + if r.addon { + line.push_str(" [addon]"); + } + line + }) + .collect(); + out.push(format!("{} CVar(s)", rows.len())); + out +} + +/// `help [command]` — the registry's own listing. +fn help(world: &mut World, args: &str) -> Vec { + let table = world.resource::(); + let wanted = args.split_whitespace().next(); + match wanted { + Some(name) => match table.by_key.get(&name.to_ascii_lowercase()) { + Some(c) => vec![format!("{} — {}", c.name, c.help)], + None => vec![format!("console: no command named '{name}'")], + }, + None => { + let mut out: Vec = table + .by_key + .values() + .map(|c| format!("{} — {}", c.name, c.help)) + .collect(); + out.push( + "…and every CVar by name: `` prints it, ` ` sets it".into(), + ); + out + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn console_app() -> App { + let mut app = App::new(); + app.add_plugins(bevy::MinimalPlugins) + .init_resource::() + .add_plugins(ConsolePlugin); + app + } + + fn run(app: &mut App, line: &str) -> Vec { + execute(app.world_mut(), line) + } + + /// **The lookup order is the reference's**: a registered command first, then the CVar's own + /// per-name command (bare name prints, a value sets), then unknown — case-insensitively at + /// every step, like the engine's own table lookup. + #[test] + fn a_line_is_a_command_then_a_cvar_then_unknown() { + let mut app = console_app(); + assert_eq!( + run(&mut app, "MusicVolume"), + vec!["CVar \"MusicVolume\" is \"0.4\"".to_string()] + ); + assert!( + run(&mut app, "musicvolume 0.7").is_empty(), + "a set is quiet" + ); + assert_eq!( + app.world().resource::().get("MusicVolume"), + Some("0.7") + ); + assert_eq!( + run(&mut app, "SET MusicVolume 0.2"), + Vec::::new(), + "`set` is a registered command, matched case-insensitively" + ); + assert_eq!( + app.world().resource::().get("MusicVolume"), + Some("0.2") + ); + assert_eq!( + run(&mut app, "cvar_default musicVolume"), + Vec::::new() + ); + assert_eq!( + app.world().resource::().get("MusicVolume"), + Some("0.4") + ); + assert_eq!( + run(&mut app, "nosuchthing 1"), + vec![ + "console: 'nosuchthing' is not a command or a CVar (`help` lists the commands)" + .to_string() + ] + ); + assert_eq!( + run(&mut app, "set nosuchthing 1"), + vec!["console: no CVar named 'nosuchthing'".to_string()], + "the stated divergence: no record conjured at the console" + ); + assert_eq!( + run(&mut app, "MusicVolume wat"), + vec!["console: 'wat' is not a number, and MusicVolume takes one".to_string()] + ); + assert_eq!( + run(&mut app, " "), + vec!["console: no command given (`help` lists the commands)".to_string()] + ); + } + + /// A write to a latched row says it is staged, prints as staged, and the list shows it. + #[test] + fn a_latched_cvar_reports_its_stage() { + let mut app = console_app(); + assert_eq!( + run(&mut app, "gxVSync 0"), + vec!["gxVSync staged as \"0\" — applies at the next RestartGx".to_string()] + ); + assert_eq!( + run(&mut app, "gxvsync"), + vec!["CVar \"gxVSync\" is \"1\" (\"0\" staged until the next RestartGx)".to_string()] + ); + let list = run(&mut app, "cvarlist gxv"); + assert_eq!( + list, + vec![ + "gxVSync = \"1\" [staged \"0\"]".to_string(), + "1 CVar(s)".to_string() + ] + ); + } + + /// `cvarlist` marks a moved value with its default, a session-owned row, and an addon's. + #[test] + fn cvarlist_says_where_each_row_stands() { + let mut app = console_app(); + { + let mut cvars = app.world_mut().resource_mut::(); + cvars.set("farclip", "500"); + cvars.own_for_session("uiScale", Some("1.2")); + cvars.learn_addon_row("myAddonKnob", "7"); + } + let list = run(&mut app, "cvarlist"); + assert!( + list.contains(&"farclip = \"500\" (default \"350\")".to_string()), + "{list:?}" + ); + assert!( + list.contains(&"uiScale = \"1.2\" (default \"0.9\") [session]".to_string()), + "{list:?}" + ); + assert!( + list.contains(&"myAddonKnob = \"7\" [addon]".to_string()), + "{list:?}" + ); + assert!(list.last().unwrap().ends_with(" CVar(s)")); + let some = run(&mut app, "cvarlist FARCLIP"); + assert_eq!(some.len(), 2, "matched case-insensitively: {some:?}"); + } + + /// `help` lists what is registered, and a subsystem's registration is one call. + #[test] + fn help_lists_the_registry_and_a_registration_is_one_call() { + fn shout(_: &mut World, args: &str) -> Vec { + vec![format!("{}!", args.to_ascii_uppercase())] + } + let mut app = console_app(); + app.console_command("shout", "Shout the argument back.", shout); + assert_eq!( + run(&mut app, "Shout hello there"), + vec!["HELLO THERE!".to_string()] + ); + let listing = run(&mut app, "help"); + assert!(listing.contains(&"shout — Shout the argument back.".to_string())); + assert!(listing.contains(&"set — Set the value of a CVar.".to_string())); + assert_eq!( + run(&mut app, "help cvarlist"), + vec!["cvarlist — List the CVars, optionally matching a string.".to_string()] + ); + } + + #[test] + #[should_panic(expected = "registered twice")] + fn a_name_registered_twice_is_a_wiring_bug() { + fn nothing(_: &mut World, _: &str) -> Vec { + Vec::new() + } + let mut app = console_app(); + app.console_command("set", "again", nothing); + } +} diff --git a/crates/benilla-app/src/cursor.rs b/crates/benilla-app/src/cursor.rs index 9ee680ff..57617f70 100644 --- a/crates/benilla-app/src/cursor.rs +++ b/crates/benilla-app/src/cursor.rs @@ -336,11 +336,23 @@ impl Plugin for CursorPlugin { app.init_resource::(); #[cfg(target_os = "macos")] app.add_systems(Startup, macos::setup.after(AssetSet::Open)) - .add_systems(Update, (drive_displayed_cursor, macos::drive).chain()); + .add_systems( + Update, + (drive_displayed_cursor, macos::drive) + .chain() + // After the tick: a FrameXML `SetCursor` made this frame is read here. + .after(crate::ui_script::UiInput), + ); #[cfg(not(target_os = "macos"))] app.init_resource::() .add_systems(Startup, other::setup.after(AssetSet::Open)) - .add_systems(Update, (drive_displayed_cursor, other::drive).chain()); + .add_systems( + Update, + (drive_displayed_cursor, other::drive) + .chain() + // After the tick: a FrameXML `SetCursor` made this frame is read here. + .after(crate::ui_script::UiInput), + ); } } diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 250aa5eb..0a02335d 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -1,66 +1,61 @@ -//! benilla's **CVar host** — registration, knob sync, and the client's first persistence -//! (decision 0954). The engine holds the table and the Lua API ([`benilla_ui::script`]'s -//! `GetCVar`/`SetCVar`); this module is everything host-side: +//! benilla's **CVar registry** — the client's configuration store, host side (decisions 0954, +//! 2303). The reference keeps every player setting in one engine-side table +//! (`ConsoleVar.cpp`, a `TSHashTable` of 0xc4-byte records: name, value, default, the +//! latch slot, and a change callback the owning subsystem hands `CVar::Register 0x63db90`). +//! This module is that table: [`Cvars`] holds the live value of every registered row, it is +//! what survives a VM replacement, and it is what `config.toml` is composed from. The script VM +//! carries a **mirror** of it for Lua's synchronous `GetCVar`/`SetCVar` +//! ([`benilla_ui::script::UiScript::seed_cvars`]); the mirror's writes ride a queue back here. //! //! - **The registered set** ([`REGISTERED`]): only vars something actually reads — a host knob, -//! or (since 1140) a live Lua consumer, which is the same rule seen from the UI side and the -//! only refinement the honest-tree law has needed. -//! -//! **A row's default is the REFERENCE's default** (decision 1804), and every row says where it -//! stands against it — that is [`Registered::reference`], a mandatory third column with no -//! "unknown" variant, so a new row cannot be added without answering the question. Two tests -//! hold it: one checks each row's claim in both directions (a `Same` that drifted, *and* a -//! `Deviates` that quietly came back into agreement), the other pins the deviation set as a -//! readable list. Beside that, and unchanged, each default is still **welded to the code -//! constant it mirrors** — `SoundConfig`, `NameConfig`, `ZoomLimit` and the rest — so the CVar -//! table and the knob it feeds cannot drift apart either. +//! or (since 1140) a live Lua consumer. **A row's default is the REFERENCE's default** +//! (decision 1804), and every row says where it stands against it — [`Registered::reference`], +//! a mandatory column with no "unknown" variant. Two tests hold it: one checks each row's claim +//! in both directions, the other pins the deviation set as a readable list. A row the reference +//! **latches** (flag bit1 at its register site) says so with [`Registered::latched`]; a +//! structural test insists every row has a reader somewhere in the source. //! //! **The reference column has one source**: wow-re's //! `system/cvar/scratch/registered-defaults-census.md` and the regenerable manifest beside it, //! `re/cvar/cvar-register-sites.tsv` — all 214 of the reference's `CVar::Register` sites with -//! name, help, flags, default string, callback, category and record global. 1804 dispatched the -//! §5 round that built it; a new row looks its answer up there rather than re-deriving it, and a -//! row that disagrees with it is a contradiction to resolve before it lands (`method.md`). -//! - **Boot**: read `benilla-config/config.toml` ([`crate::local_state`]) and apply it to the knob -//! resources; when the UI VM exists, register the table and push the resolved session values -//! so `GetCVar` answers what the client is actually doing. -//! - **Sync**: drain Lua `SetCVar` changes into the knob resources each frame and mark the -//! config dirty. +//! name, help, flags, default string, callback, category and record global. A new row looks +//! its answer up there rather than re-deriving it. +//! +//! - **The change callback is a Bevy observer** ([`CvarChanged`]). An accepted move of a row's +//! applied value is triggered as one event, and the subsystem that owns the knob observes it +//! beside the knob (`sound::on_cvar`, `video::on_cvar`, …), writing only its own resource — so +//! `Res::is_changed()` on a knob is honest again, which the 32-resource bundle this replaced +//! could not offer (it deref-mutted every knob on every write; three consumers carried the +//! written workaround). The registry never applies anything itself: it has no knob, and no arm. +//! +//! - **The latch** ([`Row::pending`]): a write to a latched row is staged, `GetCVar` keeps +//! answering the applied value, and nothing fires until [`Cvars::commit_latched`] — which is +//! the reference's `CVar::Update 0x63e060`, called for the `gx*` rows from inside `RestartGx` +//! (the video window's Okay). A staged value that is never committed is dropped at exit, as +//! the reference's is (`SaveConfig` writes `rec+0x20`, the applied value). +//! +//! - **Boot**: read `benilla-config/config.toml` ([`crate::local_state`]) into the registry and +//! fire the observers synchronously, before anything ordered after [`CvarLoad`] runs; when the +//! UI VM exists, seed its mirror from the registry so `GetCVar` answers what the client is doing. +//! - **Sync**: per frame, drain the VM's registrations and writes into the registry, push the +//! registry's own writes into the mirror, and trigger the accepted moves. //! - **Save**: dirty + one quiet second → rewrite `config.toml` atomically (and flush on //! `AppExit`). The file holds **only values that moved off their default** — a diff, not a -//! dump — plus any entries this build doesn't know (a newer build's keys survive a downgrade; -//! same posture for a hand-added key: preserved verbatim, warned once). +//! dump — plus any entries this build doesn't know (a newer build's keys, an addon's before it +//! registers them: preserved verbatim, warned once). //! //! **Env overrides win for the session and never touch the file**: `WOW_UI_SCALE`/`WOW_FARCLIP` -//! beat the loaded config (they exist to make taste iteration a relaunch — pinning one into the -//! config would make the A/B sticky), the session runs and saves around them, and the file keeps -//! whatever it already said for those keys. +//! and their siblings beat the loaded config (they exist to make taste iteration a relaunch — +//! pinning one into the config would make the A/B sticky), the session runs and saves around +//! them, and the file keeps whatever it already said for those keys ([`Cvars::own_for_session`]). -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::time::Instant; use bevy::prelude::*; -use crate::chat_bubble::BubbleConfig; -use crate::minimap::MinimapZoom; -use crate::nameplates::NameConfig; -use crate::player::camera::{ - FollowConfig, FollowStyle, LookConfig, ZoomLimit, CAMERA_SPEED_RANGE, FOLLOW_SPEED_RANGE, - MOUSE_SPEED_RANGE, -}; -use crate::portrait::PaneRate; -use crate::sound::SoundConfig; -use crate::target::ClickConfig; -use crate::ui_chat::combat::UnitClass as CombatClass; -use crate::ui_loot::LootConfig; -use crate::ui_script::UiScaleCvar; -use crate::video::VideoConfig; -use crate::vplates::VPlateMode; -use crate::world_backdrop::{RenderScale, RENDER_SCALE_RANGE}; -use benilla_ui::script::UiScript; -use benilla_ui::widget::MINIMAP_ZOOM_LEVELS; -use benilla_world::clutter::ClutterConfig; -use benilla_world::view::{MsaaSetting, ViewDistance, FARCLIP_RANGE, MSAA_RANGE}; +use crate::ui_script::VmMemo; +use benilla_ui::script::{SeededCvar, UiScript}; /// One host-backed CVar: its registered name, benilla's shipped default, and — the column that /// exists so a divergence is a *decision* rather than an accident — **what the reference ships** @@ -88,6 +83,21 @@ pub(crate) struct Registered { /// choice above it, never a value this client acts on. #[allow(dead_code)] pub(crate) reference: Reference, + /// The reference registers it with flag bit1 (`rec+0x1c & 0x2`, decision 2303): a write is + /// staged in [`Row::pending`] and applied only at the latch boundary + /// ([`Cvars::commit_latched`]). Read off `re/cvar/cvar-register-sites.tsv`'s `flags` column + /// (2 or 3); a test pins the set. + pub(crate) latched: bool, +} + +impl Registered { + /// Mark the row latched — see [`Registered::latched`]. + pub(crate) const fn latched(self) -> Self { + Self { + latched: true, + ..self + } + } } /// benilla's default, weighed against the reference's own. @@ -143,6 +153,7 @@ const fn same(name: &'static str, default: &'static str) -> Registered { name, default, reference: Reference::Same(default), + latched: false, } } @@ -158,6 +169,7 @@ const fn overridden( name, default, reference: Reference::Overridden { registered, why }, + latched: false, } } @@ -173,6 +185,7 @@ const fn deviates( name, default, reference: Reference::Deviates { value, why }, + latched: false, } } @@ -182,6 +195,7 @@ const fn ours(name: &'static str, default: &'static str, why: &'static str) -> R name, default, reference: Reference::Ours(why), + latched: false, } } @@ -214,7 +228,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // The address of the logon server — the reference's own CVar, byte-verified in `WoW.exe` // (the registration's string neighbours are `realmlist.wtf`, "Address of realm list server" // and `us.logon.worldofwarcraft.com:3724`; wow-re `mpq/scratch/startup-order-A.md` row 62). - // A **string** row, so it is matched ahead of the numeric parse in `apply_to_knobs`. + // A **string** row: the registry takes any string for it, and `realmlist::on_cvar` judges it. // The default diverges knowingly — see `realmlist::DEFAULT_REALMLIST`. deviates( crate::realmlist::CVAR_REALMLIST, @@ -289,7 +303,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // larger of its two, because the stall the crackle was measured from was a whole IO cycle // long and the depth exists to hide the next one. Applies at the next launch, like the // reference's. - same("SoundBufferSize", "100"), + same("SoundBufferSize", "100").latched(), // The output limiter (1551) — benilla's own, not a 1.12 CVar. The reference needs no such DSP // (its mix is FMOD 3's and its headroom lives in the SFX-bus auto-duck); benilla sums into f32 // behind a hard clamp, and every WoW SFX is mastered to full scale, so two overlapping kits @@ -651,7 +665,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // hold honestly rather than by picking a number that merely looks right. // // The validator is `0x50c000` → `0x50b330`, range [0.1, 360], and it **rejects rather than - // clamps** — `apply_to_knobs` does the same below, which is why these two do not use the + // clamps** — `player::camera::on_cvar` does the same, which is why these two do not use the // clamping shape every other numeric row uses. same("cameraYawMoveSpeed", "180"), same("cameraPitchMoveSpeed", "90"), @@ -663,8 +677,8 @@ pub(crate) const REGISTERED: &[Registered] = &[ // **The spelling is FrameXML's, not the binary's**, and that is deliberate: `WoW.exe` holds // `mouseSpeed` (capital S, register site `0x402c7b`) while `UIOptionsFrame.lua`'s slider // writes `cvar = "mousespeed"`. The reference reconciles them by looking CVars up - // case-insensitively (`SStrCmpI`, wow-re `cvar/cvar.md`), and so do we (`apply_to_knobs` - // lowercases first), so both spellings answer. We take the one the interface uses, because + // case-insensitively (`SStrCmpI`, wow-re `cvar/cvar.md`), and so do we (the registry and + // every observer lowercase first), so both spellings answer. We take the one the interface uses, because // that is the one an addon will type. // // **The VALUE agrees and the MECHANISM does not** (wow-re @@ -845,7 +859,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // byte-verified (wow-re `addon-version-gate.md` §1.1: the key appears in Config.wtf exactly // while force-load is on and vanishes when it is turned off, `SaveConfig 0x63d980`'s // skip-default rule). No host knob: its consumers are the load walk (via the persisted value, - // [`CvarPersist::addon_version_check`]) and the gate's live per-query read in the VM. + // [`Cvars::addon_version_check`]) and the gate's live per-query read in the VM. same("checkAddonVersion", "1"), // **Which graphics API this run is actually on** (2151) — 1.12's own `gxApi`, byte-read at // `0x63a833`: name `0x842a64`, default string `0x864f7c` `"direct3d"`, help "graphics api", @@ -879,7 +893,8 @@ pub(crate) const REGISTERED: &[Registered] = &[ "direct3d", "2151: descriptive, not a selector — benilla renders through wgpu, which has no D3D9 \ backend and no chooser; the value is the live adapter's own and is never persisted", - ), + ) + .latched(), // Vertical Sync — 1.12's own `gxVSync`, the Video Options checkbox at index 5 // (`OptionsFrame.lua`'s `OptionsFrameCheckButtons["VERTICAL_SYNC"]`, in the install's // FrameXML). The knob is [`crate::video::VideoConfig::vsync`], which the window's @@ -892,7 +907,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // Two knowing departures from the reference row, both stated on [`crate::video`]: its // `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes // effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below. - same("gxVSync", "1"), + same("gxVSync", "1").latched(), // **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern // Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the // two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from @@ -915,14 +930,14 @@ pub(crate) const REGISTERED: &[Registered] = &[ // caught its own instrument publishing a single arm mid-round). benilla is enUS-only, so `"0"` // is the answer here; the note exists so the next reader does not take a locale-conditional // default for an unconditional one. - same("gxWindow", "0"), + same("gxWindow", "0").latched(), // The **windowed** size, `gxResolution` — 1.12's own CVar name, narrowed to half its job. // There it is the display mode *and* the backbuffer; here it is only what "windowed" means, // because fullscreen is the monitor's own size and we expose no mode list to pick from (the // deviation decision 1092 already records for `GxAspect`, unchanged by 1627). // - // A **string** CVar, like it is in the reference — the one row [`apply_to_knobs`] has to match - // ahead of its numeric parse. Default is the 1600×900 that was the client's only size before + // A **string** CVar, like it is in the reference — the registry takes any string for it and + // `video::on_cvar` parses it. Default is the 1600×900 that was the client's only size before // 1627, so a windowed run is bit-for-bit where it was. deviates( "gxResolution", @@ -930,7 +945,8 @@ pub(crate) const REGISTERED: &[Registered] = &[ "640x480", "1627: narrowed to the WINDOWED size only — fullscreen is the monitor's own and we expose \ no mode list, and 640x480 is not a window anyone would ship a client at", - ), + ) + .latched(), // The body panes' half-rate render (decision 1444) — **benilla's own CVar**, no 1.12 // counterpart: the reference draws its doll inside the main pass (no second view exists to // rate-limit), while our RTT booths (1069) re-run the render graph per pane per frame. "1" = @@ -950,15 +966,14 @@ pub(crate) const REGISTERED: &[Registered] = &[ // The select screen's memory of who you last entered the world as (decision 1622) — 1.12's // own `lastCharacterIndex`, help string "Last character selected". **No host knob**: the live // value is the character screen's own state ([`crate::char_select::Roster::pending_index`]), - // which this row only mirrors — the `statusBarText` posture, and why the arm in - // [`apply_to_knobs`] is empty. + // which this row only mirrors — the `statusBarText` posture, and why no observer watches it. // // Registered **"0"**, byte-read rather than chosen: `CVar::Register` at `0x402d93` pushes // default string `0x82e570` = "0", category 4, and caches the CVar* at `[0x882674]`. The value // is a **0-based** row (the engine's selection cell `[0x83856c]` under `"%d"`), so "0" is the // FIRST character and not a "no memory" sentinel — which is exactly why a stock `Config.wtf` // has no such line until you have played somebody other than your first character - // (`SaveConfig 0x63d980` skips values equal to their default; `compose_file` does the same). + // (`SaveConfig 0x63d980` skips values equal to their default; [`Cvars::compose`] does the same). // Multisample antialiasing — 1.12's own `gxMultisample`, registered at `0x63a950` with help // "multisample antialiasing" and flags `3` = registered | **latched**. The knob is // [`benilla_world::view::MsaaSetting`], read once at the world camera's spawn; its doc carries @@ -976,7 +991,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // Latched means a change is PENDING until the next launch — the reference's own callback // echoes "set pending gxRestart" — so this row persists and `GetCVar` answers it, while the // camera keeps what it was born with. `$WOW_MSAA` overrides it session-only, below. - same("gxMultisample", "1"), + same("gxMultisample", "1").latched(), // The multisample triple's other two thirds. The reference's Video dropdown formats all three // into one row (`MULTISAMPLING_FORMAT_STRING` = "%d-bit color %d-bit depth %dx multisample") // and `GetCurrentMultisampleFormat 0x48c580` looks up all three by name to find which row is @@ -996,13 +1011,15 @@ pub(crate) const REGISTERED: &[Registered] = &[ "16", "1643: these describe, they do not steer — the pair is our swapchain's own, and every \ format `MsaaFormats` publishes carries it", - ), + ) + .latched(), deviates( "gxDepthBits", "32", "16", "1643: as `gxColorBits` — the depth half of the same descriptive pair", - ), + ) + .latched(), // **The texture filter policy** — 1.12's own `trilinear` and `anisotropic`, over // `benilla_assets::TexFilterSetting`. The defaults are the reference's registered strings, and // benilla had neither CVar: it hardcoded trilinear + aniso 8 at every sampler it built, which @@ -1126,12 +1143,97 @@ struct LocalConfig { cvars: BTreeMap, } -/// The persistence state: what the file said, which keys the environment overrides this -/// session, and the dirty/debounce pair. -#[derive(Resource, Default)] -pub(crate) struct CvarPersist { - /// The file's `[cvars]` entries, verbatim spelling — the merge base every save starts from - /// (unknown keys ride through untouched, session-owned keys keep their stored value). +// ─── The registry ──────────────────────────────────────────────────────────────────────────── + +/// **One accepted move of a CVar's applied value** — the reference's change callback, as the +/// Bevy event it is (decision 2303). Triggered by the registry's flushers for every write that +/// changed a row's applied value (a Lua `SetCVar`, a `/console` line, a host write, a committed +/// latch, and the loaded file at boot); observed by the subsystem that owns the knob, beside the +/// knob, writing only its own resource. +/// +/// Not fired for a write that changed nothing, for a staged (latched) value, or for a session +/// override taking a row (the knob already read the env). +#[derive(Event, Clone, Debug, PartialEq, Eq)] +pub(crate) struct CvarChanged { + /// The registered spelling (`MasterVolume`). + pub(crate) name: String, + pub(crate) old: String, + pub(crate) new: String, +} + +impl CvarChanged { + /// Case-insensitive, like every lookup the client makes. + pub(crate) fn is(&self, name: &str) -> bool { + self.name.eq_ignore_ascii_case(name) + } + + /// The lowercased name — what an observer's `match` arms are spelled in. + pub(crate) fn key(&self) -> String { + self.name.to_ascii_lowercase() + } + + /// The new value as a number. **The registry refuses an unparseable write to a numeric + /// row** ([`Cvars::set`]), so on a row whose default is a number this is never a fallback; + /// on a string row it is `0`, and an observer for a string row reads [`Self::new`] instead. + pub(crate) fn num(&self) -> f32 { + self.new.trim().parse().unwrap_or(0.0) + } + + /// The new value as the client's flag: int-parse, then `!= 0`. + pub(crate) fn flag(&self) -> bool { + self.num() != 0.0 + } +} + +/// One row of the live registry — the reference's `CVar` record, the parts benilla keeps. +#[derive(Clone, Debug)] +pub(crate) struct Row { + /// The registered spelling. + pub(crate) name: String, + pub(crate) default: String, + /// The **applied** value: what `GetCVar` answers and what the file is composed from. + pub(crate) value: String, + /// A latched row's staged value (`rec+0x38`), applied by [`Cvars::commit_latched`]. + pub(crate) pending: Option, + /// [`Registered::latched`] — the reference's flag bit1. + pub(crate) latched: bool, + /// Declared by an addon's `RegisterCVar` rather than by [`REGISTERED`] (decision 1195): it + /// persists like any other row, and it is re-seeded into every later VM so the addon's own + /// re-declaration finds it and no-ops. + pub(crate) addon: bool, +} + +impl Row { + /// A row whose default parses as a number holds numbers — the registry refuses a write + /// that does not parse ([`Cvars::set`]), so a numeric observer can trust [`CvarChanged::num`]. + fn numeric(&self) -> bool { + self.default.trim().parse::().is_ok() + } +} + +/// What a write did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SetOutcome { + /// No such row (warned). + Unknown, + /// A numeric row, and the value does not parse — refused, the applied value stands (warned). + Refused, + /// Already the applied value (or already the staged one). + Unchanged, + /// A latched row: staged, not applied; nothing fires until the commit. + Staged, + /// Applied: a [`CvarChanged`] is queued for the next flush and the config is dirty. + Changed, +} + +/// **The registry** — the engine-side CVar table. See the module doc. +#[derive(Resource)] +pub(crate) struct Cvars { + rows: Vec, + /// Lowercased name → row. + index: HashMap, + /// The file's `[cvars]` entries, verbatim spelling — the merge base every save starts from: + /// unknown keys ride through untouched, session-owned keys keep their stored value. file: BTreeMap, /// Lowercased names this SESSION owns rather than the player — never saved, and the file's /// own entry for them is left exactly as it was found. @@ -1140,67 +1242,387 @@ pub(crate) struct CvarPersist { /// that stuck in `config.toml` would make an A/B or an instrument run sticky across /// relaunches. `gxApi` (2151) is the member that is not — it is owned by the session because /// it is a fact about the *machine* (the render adapter's backend), which is nobody's setting - /// to persist. The field was `env_overridden` until it gained that one. + /// to persist. session_owned: HashSet, - /// The engine table has been registered + seeded — **once per VM**, not once per process - /// (decision 1290). A login builds a fresh VM, so the seed has to happen again: an - /// unregistered table answers every `GetCVar` with nil, and [`save_config`] composes - /// `config.toml` out of that same table. - registered: crate::ui_script::VmMemo, + /// Accepted moves not yet triggered — flushed by [`sync_cvars`] every frame, synchronously by + /// the boot load and the session-edge fold, and by any caller that wants its observers to + /// have run before its own dependents ([`Cvars::take_events`]). + events: Vec, + /// Host-side writes the VM's mirror has not seen yet — pushed by [`sync_cvars`] as host + /// writes (no echo). Cleared by a seed, which carries the whole table anyway. + outbox: Vec<(String, String)>, /// A change since the last save; `last_change` drives the one-quiet-second debounce. dirty: bool, last_change: Option, } -impl CvarPersist { - /// The saved-base pairs a VM's table is seeded from — the file's entries minus the ones the - /// session owns ([`CvarPersist::session_owned`]), which are never persisted. - /// - /// Extracted so `ui_script::lifecycle`'s world-entry edge can run the same seed before the - /// interface loads (decision 2115): the reference's own `UIOptionsFrame.xml` reads two CVars - /// in its dropdowns' `OnLoad`, and a `/reloadui` builds a fresh VM and loads the whole - /// interface before [`sync_cvars`]'s `Update` claim gets a turn. The ORDER at both call sites - /// is this first, `register_cvars` second (1291) — reversed, a reload resets every knobless - /// CVar to its factory value. - pub(crate) fn saved_base(&self) -> impl Iterator + '_ { - self.file - .iter() - .filter(|(k, _)| !self.session_owned.contains(&k.to_ascii_lowercase())) - .map(|(k, v)| (k.clone(), v.clone())) - } - - /// One CVar as `config.toml` holds it — matched case-insensitively, so a hand-edited - /// spelling still answers. - /// - /// Read from the persist state rather than from the VM's table for the callers that want a - /// value **before, or outside, a registered table**: the addon load walk runs while the VM's - /// CVar table does not exist yet (registration is a per-VM `Update` seed, 1291), and the - /// select screen wants its remembered row the moment a roster lands, from a system that has - /// no business holding the VM (1622). The 1291 fold keeps this current across VM - /// replacements, so it is the value the reference's live read would see. - pub(crate) fn stored(&self, name: &str) -> Option<&str> { - self.file - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(name)) - .map(|(_, v)| v.as_str()) - } - - /// A persist state that already holds one stored value — a launch whose `config.toml` said - /// so, without a file. `#[cfg(test)]` and `pub(crate)` because [`Self::file`] is private: - /// `char_select`'s restore test drives the real [`apply_roster_policy`] over a real remembered - /// row rather than a copy of its logic (the `Roster::with_pending_pick` posture). - #[cfg(test)] - pub(crate) fn with_stored(name: &str, value: &str) -> Self { - Self { - file: BTreeMap::from([(name.to_string(), value.to_string())]), - ..Self::default() +impl Default for Cvars { + fn default() -> Self { + let mut cvars = Self { + rows: Vec::with_capacity(REGISTERED.len()), + index: HashMap::with_capacity(REGISTERED.len()), + file: BTreeMap::new(), + session_owned: HashSet::new(), + events: Vec::new(), + outbox: Vec::new(), + dirty: false, + last_change: None, + }; + for r in REGISTERED { + cvars.insert_row(Row { + name: r.name.to_string(), + default: r.default.to_string(), + value: r.default.to_string(), + pending: None, + latched: r.latched, + addon: false, + }); } + cvars + } +} + +impl Cvars { + fn insert_row(&mut self, row: Row) { + let key = row.name.to_ascii_lowercase(); + debug_assert!( + !self.index.contains_key(&key), + "{}: registered twice", + row.name + ); + self.index.insert(key, self.rows.len()); + self.rows.push(row); + } + + fn slot(&self, name: &str) -> Option { + self.index.get(&name.to_ascii_lowercase()).copied() + } + + /// The row, matched case-insensitively. + pub(crate) fn row(&self, name: &str) -> Option<&Row> { + self.slot(name).map(|i| &self.rows[i]) + } + + /// Every row, in registration order. + pub(crate) fn rows(&self) -> impl Iterator { + self.rows.iter() + } + + /// The applied value. + pub(crate) fn get(&self, name: &str) -> Option<&str> { + self.row(name).map(|r| r.value.as_str()) + } + + /// The applied value as a number, `None` for an unknown row or a non-numeric value. + pub(crate) fn num(&self, name: &str) -> Option { + self.get(name).and_then(|v| v.trim().parse().ok()) + } + + /// The applied value as the client's flag (int-parse, `!= 0`), `None` for an unknown row. + pub(crate) fn flag(&self, name: &str) -> Option { + self.num(name).map(|v| v != 0.0) + } + + /// The registered default. + pub(crate) fn default_of(&self, name: &str) -> Option<&str> { + self.row(name).map(|r| r.default.as_str()) + } + + /// Whether this session owns the row rather than the player. + pub(crate) fn is_session_owned(&self, name: &str) -> bool { + self.session_owned.contains(&name.to_ascii_lowercase()) } /// The persisted `checkAddonVersion` (decision 1292) — what the addon load walk gates on. - /// Absent = the registrar default: check ON. + /// The registrar default is check ON. pub(crate) fn addon_version_check(&self) -> bool { - self.stored("checkAddonVersion").is_none_or(|v| v != "0") + self.flag("checkAddonVersion").unwrap_or(true) + } + + fn touch(&mut self) { + self.dirty = true; + self.last_change = Some(Instant::now()); + } + + /// The write, from either side of the VM boundary. `from_vm` is a write the mirror has + /// already made (a Lua `SetCVar`, `ConsoleExec`, an engine verb), so it is not echoed back; + /// a refusal IS pushed back, because the mirror stored what this refused. + fn write(&mut self, name: &str, value: &str, from_vm: bool) -> SetOutcome { + let Some(i) = self.slot(name) else { + warn!("cvar {name}: not registered — write ignored"); + return SetOutcome::Unknown; + }; + let row = &mut self.rows[i]; + if row.numeric() && value.trim().parse::().is_err() { + warn!( + "cvar {}: unparseable value '{value}' refused (still {:?})", + row.name, row.value + ); + if from_vm { + self.outbox.push((row.name.clone(), row.value.clone())); + } + return SetOutcome::Refused; + } + if row.latched { + // The reference's `Set 0x63df50` on flag bit1: `latchedValue` takes the string and + // `InternalSet` does not run — no dirty, no callback. Staging the applied value + // back clears the stage. + // The stage lives HERE, not in the mirror: the VM only ever learns applied values + // (a host write into the mirror IS a commit there), so a host-side stage is not + // echoed — `GetCVar` keeps answering the applied value either way, and the commit + // below is what reaches the VM. + let staged = (value != row.value).then(|| value.to_string()); + if row.pending == staged { + return SetOutcome::Unchanged; + } + row.pending = staged; + return if row.pending.is_some() { + SetOutcome::Staged + } else { + SetOutcome::Unchanged // the stage cleared: the boundary has nothing to do + }; + } + if row.value == value { + return SetOutcome::Unchanged; + } + let old = std::mem::replace(&mut row.value, value.to_string()); + let name = row.name.clone(); + self.events.push(CvarChanged { + name: name.clone(), + old, + new: value.to_string(), + }); + if !from_vm { + self.outbox.push((name, value.to_string())); + } + self.touch(); + SetOutcome::Changed + } + + /// **A host-side write** — the counterpart of a Lua `SetCVar`, for the engine's own values: + /// the minimap zoom, the camera views, the remembered character, the loading screen's tip + /// cursor. Mirrored into the VM, persisted, and observed like any other write. + pub(crate) fn set(&mut self, name: &str, value: &str) -> SetOutcome { + self.write(name, value, false) + } + + /// A write the VM's mirror already made — drained from its change queue. + pub(crate) fn set_from_vm(&mut self, name: &str, value: &str) -> SetOutcome { + self.write(name, value, true) + } + + /// **The table follows a value the engine already applied** — a mirror, not a write: the + /// applied value moves, the config dirties, the VM's mirror learns it, and **no observer + /// fires**, because the knob is already there. For a second registered spelling of one knob + /// (`WorldDetail`/`frillDensity`, 2151) — an observer that answered its sibling's move with a + /// full write would queue an event that lands one flush later, by which time the row may + /// have moved again, and the stale event would win. Returns whether the row moved. + pub(crate) fn mirror(&mut self, name: &str, value: &str) -> bool { + let Some(i) = self.slot(name) else { + warn!("cvar {name}: not registered — mirror ignored"); + return false; + }; + let row = &mut self.rows[i]; + if row.value == value { + return false; + } + row.value = value.to_string(); + row.pending = None; + self.outbox.push((row.name.clone(), value.to_string())); + self.touch(); + true + } + + /// **The latch boundary** — the reference's `CVar::Update 0x63e060`: every staged value + /// becomes the applied one, fires, persists, and reaches the mirror. Returns how many moved. + /// Called for the `gx*` rows from `RestartGx` ([`crate::video`]); a row nothing ever commits + /// (`SoundBufferSize`, `gxApi`) holds its stage until exit, and loses it there, as the + /// reference does. + pub(crate) fn commit_latched(&mut self) -> usize { + let mut moved = 0; + for row in &mut self.rows { + let Some(staged) = row.pending.take() else { + continue; + }; + if staged == row.value { + continue; + } + let old = std::mem::replace(&mut row.value, staged.clone()); + self.events.push(CvarChanged { + name: row.name.clone(), + old, + new: staged.clone(), + }); + self.outbox.push((row.name.clone(), staged)); + moved += 1; + } + if moved > 0 { + self.touch(); + } + moved + } + + /// **This session owns the row**: an env lever or the machine's own fact took it, so it is + /// never saved and the file's entry for it is left alone. `value` is what the row answers + /// this session — `None` marks the row without moving it (the resource is absent, so the + /// registered default is the truth). No observer fires: the knob already read the env. + pub(crate) fn own_for_session(&mut self, name: &str, value: Option<&str>) { + let key = name.to_ascii_lowercase(); + self.session_owned.insert(key.clone()); + let Some(value) = value else { + return; + }; + let Some(&i) = self.index.get(&key) else { + warn!("cvar {name}: not registered — session value ignored"); + return; + }; + let row = &mut self.rows[i]; + if row.value != value { + row.value = value.to_string(); + row.pending = None; + self.outbox.push((row.name.clone(), value.to_string())); + } + } + + /// An addon's `RegisterCVar` (decision 1195), reported by the VM: a row of its own, starting + /// at the file's value for that name when it carries one (the 1291 bridge, now on the store + /// that survives the VM), else at the declared default. A name already registered — the + /// client's own, or the same addon's earlier declaration — is the no-op it always was. + pub(crate) fn learn_addon_row(&mut self, name: &str, default: &str) { + if self.slot(name).is_some() { + return; + } + let saved = self + .file + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.clone()); + self.insert_row(Row { + name: name.to_string(), + default: default.to_string(), + value: saved.unwrap_or_else(|| default.to_string()), + pending: None, + latched: false, + addon: true, + }); + } + + /// Fold the file in: every known, player-owned key becomes its row's applied value (an + /// accepted move, so the observers hear it — the reference's `Register` on a record + /// `Config.wtf` already created calls the callback with the file's value); an unknown key is + /// preserved for the save and warned once; a session-owned key is skipped and the file keeps + /// it. Loading is not a change: nothing is dirtied. + fn load_file(&mut self, file: BTreeMap) { + for (name, value) in &file { + let key = name.to_ascii_lowercase(); + let Some(&i) = self.index.get(&key) else { + warn!("config: unknown cvar '{name}' — preserved, not applied"); + continue; + }; + if self.session_owned.contains(&key) { + info!("config: {name} is owned by this session, not the file (file value kept)"); + continue; + } + let row = &mut self.rows[i]; + if row.numeric() && value.trim().parse::().is_err() { + warn!("config: {name}: unparseable value '{value}' ignored"); + continue; + } + if row.value == *value { + continue; + } + let old = std::mem::replace(&mut row.value, value.clone()); + self.events.push(CvarChanged { + name: row.name.clone(), + old, + new: value.clone(), + }); + } + self.file = file; + } + + /// The file's entries no row claims — a newer build's keys, or an addon's before it + /// registers them this session. Handed to the VM as its saved base, so an addon's + /// `RegisterCVar` starts at the player's value (decision 1291). + pub(crate) fn orphans(&self) -> Vec<(String, String)> { + self.file + .iter() + .filter(|(k, _)| !self.index.contains_key(&k.to_ascii_lowercase())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + } + + /// The whole table as a VM's mirror is seeded from it. + pub(crate) fn vm_seed(&self) -> Vec { + self.rows + .iter() + .map(|r| SeededCvar { + name: r.name.clone(), + value: r.value.clone(), + default: r.default.clone(), + latched: r.latched, + }) + .collect() + } + + /// Whether a flush has something to trigger — read before taking, so a quiet frame never + /// deref-muts the registry. + pub(crate) fn has_events(&self) -> bool { + !self.events.is_empty() + } + + /// The accepted moves since the last flush — the caller triggers each one. + pub(crate) fn take_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + fn has_outbox(&self) -> bool { + !self.outbox.is_empty() + } + + fn take_outbox(&mut self) -> Vec<(String, String)> { + std::mem::take(&mut self.outbox) + } + + /// Compose the file to save: the previous file as the merge base, every row that moved off + /// its default written (the **applied** value — a staged one is not the player's setting + /// yet), every one back at its default removed — session-owned keys and unknown keys + /// untouched. + fn compose(&self) -> BTreeMap { + let mut out = self.file.clone(); + for row in &self.rows { + let key = row.name.to_ascii_lowercase(); + if self.session_owned.contains(&key) { + continue; + } + // Match any existing entry case-insensitively so a hand-edited spelling doesn't fork. + let existing = out + .keys() + .find(|k| k.eq_ignore_ascii_case(&row.name)) + .cloned(); + if row.value == row.default { + if let Some(k) = existing { + out.remove(&k); + } + } else { + out.insert( + existing.unwrap_or_else(|| row.name.clone()), + row.value.clone(), + ); + } + } + out + } + + /// A registry that already holds one stored value — a launch whose `config.toml` said so, + /// without a file. For the tests that drive a real consumer over a real remembered row + /// (`char_select`'s restore test) rather than a copy of its logic. + #[cfg(test)] + pub(crate) fn with_value(name: &str, value: &str) -> Self { + let mut cvars = Self::default(); + cvars.load_file(BTreeMap::from([(name.to_string(), value.to_string())])); + cvars.events.clear(); + cvars } } @@ -1208,7 +1630,8 @@ impl CvarPersist { /// short enough that a crash loses one gesture, not a session ("write-on-change, debounced"). const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1); -/// The startup fold of `config.toml` into the knob resources ([`load_config`]). +/// The startup fold of `config.toml` into the registry and, through the observers, the knobs +/// ([`load_config`]). /// /// A set rather than a bare system because one knob is **read once and never again**: the world /// camera takes its `Msaa` at spawn (decision 1629, the reference's latched `gxMultisample`), so @@ -1228,14 +1651,18 @@ pub(crate) struct CvarPlugin; impl Plugin for CvarPlugin { fn build(&self, app: &mut App) { - app.init_resource::() + app.init_resource::() .add_systems( Startup, (load_config, publish_filter_policy) .chain() .in_set(CvarLoad), ) - .add_systems(Update, sync_cvars); + // After the tick (decision 2304): a `SetCVar` the interface made this frame reaches + // the registry — and its observers — before the frame's drains read it. The video + // window's Okay is the case: `SetCVar` per changed row, then `RestartGx()`, in one + // handler; `video::drain_restart_gx` orders after this so the commit finds the stage. + .add_systems(Update, sync_cvars.after(crate::ui_script::UiInput)); // **The flush is on the exit edge, not beside its feed** (decision 1528). It used to be // `(sync_cvars, save_config).chain()` in `Update`, which made the "or the app exiting" // half of its own gate dead on the exit a player actually causes: the close button's @@ -1246,557 +1673,124 @@ impl Plugin for CvarPlugin { } } -/// The knob resources as a **SystemParam** — the one census, fetched once, shared by all three -/// entry points ([`load_config`], [`sync_cvars`], [`fold_dying_vm_cvars`]). +/// **What the environment took for this session, and what it set it to** — read off the knobs +/// the env levers already seeded (`RenderScale::default()` reads `$WOW_RENDER_SCALE`, and so +/// on), so the registry answers `GetCVar` with the value the client is actually running at. /// -/// It exists because the census had grown past Bevy's **16-param ceiling**: with fifteen knobs, -/// `sync_cvars` (script + persist + knobs) and the fold's `SystemState` both stopped compiling the -/// moment the plate toggles landed. Re-typing the list at every call site was already the shape -/// that made a new knob a four-place edit; bundling it makes a new knob one field here, one field -/// on [`Knobs`], and one arm in [`apply_to_knobs`], and the ceiling stops being reachable. -#[derive(bevy::ecs::system::SystemParam)] -pub(crate) struct KnobParams<'w> { - sound: ResMut<'w, SoundConfig>, - scale: ResMut<'w, UiScaleCvar>, - view: ResMut<'w, ViewDistance>, - msaa: ResMut<'w, MsaaSetting>, - msaa_formats: Res<'w, benilla_world::view::MsaaFormats>, - look: ResMut<'w, LookConfig>, - click: ResMut<'w, ClickConfig>, - loot: ResMut<'w, LootConfig>, - names: ResMut<'w, NameConfig>, - plates: ResMut<'w, VPlateMode>, - clutter: ResMut<'w, ClutterConfig>, - weather: ResMut<'w, benilla_world::weather::WeatherState>, - display_gamma: ResMut<'w, crate::ui_gamma::DisplayGamma>, - minimap: ResMut<'w, MinimapZoom>, - bubbles: ResMut<'w, BubbleConfig>, - zoom: ResMut<'w, ZoomLimit>, - follow: ResMut<'w, FollowConfig>, - camera_opts: ResMut<'w, crate::player::camera_dynamics::CameraOptions>, - video: ResMut<'w, VideoConfig>, - render_scale: ResMut<'w, RenderScale>, - tex_filter: ResMut<'w, benilla_assets::TexFilterSetting>, - pane_rate: ResMut<'w, PaneRate>, - guild_notify: ResMut<'w, crate::ui_guild::GuildMemberNotify>, - text_filter: ResMut<'w, crate::text_filter::TextFilterSwitches>, - game_tip: ResMut<'w, crate::game_tip::GameTipSetting>, - block_trades: ResMut<'w, crate::ui_trade::BlockTrades>, - auto_self_cast: ResMut<'w, crate::ui_action::AutoSelfCast>, - realmlist: ResMut<'w, crate::realmlist::Realmlist>, - fps_journal: ResMut<'w, crate::perf::FpsJournalSetting>, - assist_attack: ResMut<'w, crate::target::AssistAttack>, - combat_ranges: ResMut<'w, crate::ui_chat::combat::CombatLogRanges>, - damage_text: ResMut<'w, crate::combat_text::DamageTextGates>, - log_periodic: ResMut<'w, crate::ui_chat::combat::LogPeriodicSpells>, -} - -impl KnobParams<'_> { - /// Borrow the set as [`Knobs`] for a write. - /// - /// **Deref-muts every resource, so call it only when a change is actually being applied** - /// (0992's change-detection trap: the clutter re-scatter watches `is_changed::`, - /// and a set built on every frame — or before the change queue is known to be non-empty — - /// re-scattered the world on every MasterVolume drag tick). Reading a field off `self` - /// directly, as the session seed does, goes through `Deref` and flags nothing. - fn knobs(&mut self) -> Knobs<'_> { - Knobs { - sound: &mut self.sound, - scale: &mut self.scale, - view: &mut self.view, - msaa: &mut self.msaa, - msaa_formats: &self.msaa_formats, - look: &mut self.look, - click: &mut self.click, - loot: &mut self.loot, - names: &mut self.names, - plates: &mut self.plates, - clutter: &mut self.clutter, - weather: &mut self.weather, - display_gamma: &mut self.display_gamma, - minimap: &mut self.minimap, - bubbles: &mut self.bubbles, - zoom: &mut self.zoom, - follow: &mut self.follow, - camera_opts: &mut self.camera_opts, - video: &mut self.video, - render_scale: &mut self.render_scale, - tex_filter: &mut self.tex_filter, - pane_rate: &mut self.pane_rate, - guild_notify: &mut self.guild_notify, - text_filter: &mut self.text_filter, - game_tip: &mut self.game_tip, - block_trades: &mut self.block_trades, - auto_self_cast: &mut self.auto_self_cast, - realmlist: &mut self.realmlist, - fps_journal: &mut self.fps_journal, - assist_attack: &mut self.assist_attack, - combat_ranges: &mut self.combat_ranges, - damage_text: &mut self.damage_text, - log_periodic: &mut self.log_periodic, - } +/// This is the (iv) residue 2265 named: each lever here is one the env still reaches through a +/// resource's `Default` rather than through a `category: debug` row of this table, and each +/// line goes the day its lever does. A lever whose resource is absent (a stripped test app) +/// still marks its row session-owned — the file must not apply over an env the caller set. +fn session_values(world: &World) -> Vec<(&'static str, Option)> { + let set = |k: &str| std::env::var_os(k).is_some(); + let flag = |b: bool| if b { "1" } else { "0" }.to_string(); + let mut out: Vec<(&'static str, Option)> = Vec::new(); + if set("WOW_UI_SCALE") { + let v = world.get_resource::(); + out.push(("uiScale", v.map(|s| s.0.to_string()))); } -} - -/// The knob resources one CVar write can land on, bundled so [`apply_to_knobs`] and its two -/// callers grow together (a new knob is one field + one arm). -struct Knobs<'a> { - sound: &'a mut SoundConfig, - scale: &'a mut UiScaleCvar, - view: &'a mut ViewDistance, - msaa: &'a mut MsaaSetting, - /// What the device actually offers — the ceiling `gxMultisample` is clamped to (1643). - msaa_formats: &'a benilla_world::view::MsaaFormats, - look: &'a mut LookConfig, - click: &'a mut ClickConfig, - loot: &'a mut LootConfig, - names: &'a mut NameConfig, - plates: &'a mut VPlateMode, - clutter: &'a mut ClutterConfig, - /// The weather driver's own state — `weatherDensity` writes ONE byte of it - /// ([`benilla_world::weather::WeatherState::weather_density`]), the particle-density - /// step; every other field on it is the wire's, not a setting's (2181). - weather: &'a mut benilla_world::weather::WeatherState, - /// The display-brightness ramp the UI lane's decode applies (2182). - display_gamma: &'a mut crate::ui_gamma::DisplayGamma, - minimap: &'a mut MinimapZoom, - bubbles: &'a mut BubbleConfig, - zoom: &'a mut ZoomLimit, - follow: &'a mut FollowConfig, - camera_opts: &'a mut crate::player::camera_dynamics::CameraOptions, - video: &'a mut VideoConfig, - render_scale: &'a mut RenderScale, - tex_filter: &'a mut benilla_assets::TexFilterSetting, - pane_rate: &'a mut PaneRate, - guild_notify: &'a mut crate::ui_guild::GuildMemberNotify, - text_filter: &'a mut crate::text_filter::TextFilterSwitches, - game_tip: &'a mut crate::game_tip::GameTipSetting, - block_trades: &'a mut crate::ui_trade::BlockTrades, - auto_self_cast: &'a mut crate::ui_action::AutoSelfCast, - realmlist: &'a mut crate::realmlist::Realmlist, - fps_journal: &'a mut crate::perf::FpsJournalSetting, - assist_attack: &'a mut crate::target::AssistAttack, - combat_ranges: &'a mut crate::ui_chat::combat::CombatLogRanges, - damage_text: &'a mut crate::combat_text::DamageTextGates, - log_periodic: &'a mut crate::ui_chat::combat::LogPeriodicSpells, -} - -/// **The string-valued rows**, matched ahead of the numeric parse every other row goes through — -/// which would reject them as bad values. `gxResolution` was the first (decision 1627) and its -/// comment named this as the shape a second one would join rather than a second special case -/// somewhere else; `realmList` (1667) is the second, `realmName` the third. Every arm shares the -/// numeric miss's posture below: known key, bad value — consumed, with a warn, and the resource -/// keeps its truth. -/// -/// **Split out of [`apply_to_knobs`] so the table can be held to it.** The claim -/// "a string row without an arm here is a CVar the client will never honour" was written beside -/// [`the_string_valued_cvars_are_the_realm_and_the_windowed_size`] and then not enforced: -/// `realmName` shipped with no arm, so every launch after the first connect warned -/// `cvar realmName: unparseable value 'VMaNGOS' ignored` on the way past the numeric parse. As a -/// separate `bool` this is something a test can call for every non-numeric row in the table, which -/// is what [`every_string_valued_row_is_claimed_before_the_numeric_parse`] now does. -fn apply_string_valued(key: &str, name: &str, value: &str, knobs: &mut Knobs) -> bool { - if !is_string_valued(key) { - return false; - } - match key { - "gxresolution" => match crate::video::parse_resolution(value) { - Some(size) => knobs.video.windowed = size, - None => warn!("cvar {name}: unparseable value '{value}' ignored"), - }, - "realmlist" => match crate::realmlist::normalize(value) { - Some(address) => knobs.realmlist.set(&address), - None => warn!("cvar {name}: unusable realmlist '{value}' ignored"), - }, - // No host knob, and none wanted: the live realm name is written from the session - // (`ui_script::addons::load_third_party`), and the persisted one reaches `GetCVar` through - // `set_cvar_saved_base` without passing here at all. Claimed anyway — the `statusBarText` - // posture — so the value is CONSUMED rather than falling to a numeric parse that can only - // reject it, and so a toggle still dirties the config. - "realmname" => {} - // Descriptive, not a knob (2151): the live value is the render adapter's backend, pushed - // into the table by [`sync_cvars`]. Claimed for the same reason `realmname` is — so a - // write is CONSUMED rather than falling to a numeric parse that can only reject it — and - // it goes no further: the reference latches this CVar for the next `GxDevCreate`, and we - // have no device to re-create it on. `load_config` marks it session-owned, so the write - // also never reaches `config.toml`. - "gxapi" => {} - _ => {} - } - true -} - -/// Which keys [`apply_string_valued`] claims — lowercased, and split out from the arms so a test -/// can hold the TABLE to it without building a `Knobs`. The claim it makes possible: every -/// registered row whose default does not parse as a number is named here -/// ([`every_string_valued_row_is_claimed_before_the_numeric_parse`]). -fn is_string_valued(key: &str) -> bool { - matches!(key, "gxapi" | "gxresolution" | "realmlist" | "realmname") -} - -/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this -/// build knows (the caller decides whether that warns or rides through). -fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { - let key = name.to_ascii_lowercase(); - if apply_string_valued(&key, name, value, knobs) { - return true; - } - let Ok(v) = value.parse::() else { - warn!("cvar {name}: unparseable value '{value}' ignored"); - return true; // known key, bad value — consumed, resource keeps its truth - }; - match key.as_str() { - "mastervolume" => knobs.sound.master = v.clamp(0.0, 1.0), - "soundvolume" => knobs.sound.sfx = v.clamp(0.0, 1.0), - "musicvolume" => knobs.sound.music = v.clamp(0.0, 1.0), - "ambiencevolume" => knobs.sound.ambience = v.clamp(0.0, 1.0), - // The enables are 0/1 flags; the client's own parse is int + `!= 0`. - "mastersoundeffects" => knobs.sound.enabled = v != 0.0, - "enablemusic" => knobs.sound.music_enabled = v != 0.0, - "enableambience" => knobs.sound.ambience_enabled = v != 0.0, - "enableerrorspeech" => knobs.sound.error_speech = v != 0.0, - "sound_enablesoundwhengameisinbg" => knobs.sound.background_sound = v != 0.0, - // The client's own parse for this one is literally `!= 0` too (`0x4574d0`: `setne al`). - "soundreverb" => knobs.sound.reverb = v != 0.0, - "soundoutputlimiter" => knobs.sound.limiter = v != 0.0, - "soundlisteneratcharacter" => knobs.sound.listener_at_character = v != 0.0, - "emotesounds" => knobs.sound.emote_sounds = v != 0.0, - "soundzonemusicnodelay" => knobs.sound.zone_music_no_delay = v != 0.0, - "uiscale" => knobs.scale.0 = v.clamp(0.5, 1.5), - "farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()), - // The reference REFUSES an out-of-range write here rather than clamping (`0x688d90` echoes - // "NearClip must be in range 0.01 - 0.33" and returns 0). We clamp, which is this table's - // standing posture for every range — the consumer clamps at its own edge. - "nearclip" => knobs.view.set_nearclip(v), - "deselectonclick" => knobs.click.deselect_on_click = v != 0.0, - "autoselfcast" => knobs.auto_self_cast.0 = v != 0.0, - "assistattack" => knobs.assist_attack.0 = v != 0.0, - // The sixteen camera-view CVars have no knob to apply to: `CameraViews` is their writer, - // not their reader (it seeds itself from the persisted file at startup, and `SaveView` - // writes back). They are claimed here so the table's own "not a knob this build knows" - // warning stays meaningful — an unclaimed name would look like a typo every session. - _ if crate::player::camera_view::is_view_cvar(name) => {} - "mouseinvertpitch" => knobs.look.invert_pitch = v != 0.0, - "cameradistancemaxfactor" => knobs.zoom.set_factor(v), - // The three stops are 1 Smart / 2 Always / 3 Never; anything else reads as the registrar - // default rather than as a dead camera (`FollowStyle::from_cvar`). - "camerasmoothstyle" => knobs.follow.style = FollowStyle::from_cvar(v), - // Its sibling selector — the one the reference swaps in for the externally-driven states. - "camerasmoothtrackingstyle" => knobs.follow.tracking_style = FollowStyle::from_cvar(v), - // The auto-follow rate, clamped to 1.12's own AUTO_FOLLOW_SPEED slider range. - "camerayawsmoothspeed" => { - knobs.follow.yaw_speed = - v.clamp(*FOLLOW_SPEED_RANGE.start(), *FOLLOW_SPEED_RANGE.end()); - } - // The 1.12 slider's own range; an off-grid hand-edit rides between stops, like the others. - "mousespeed" => { - knobs.look.sensitivity = v.clamp(*MOUSE_SPEED_RANGE.start(), *MOUSE_SPEED_RANGE.end()); - } - // The reference's `0x50b330` validator REJECTS an out-of-range value rather than clamping - // it: it prints `Value out of range (%f - %f)` and `CVar::Set` never stores, so the old - // value stands. That is a different posture from every clamping row above, and it is the - // faithful one — a script writing 1e9 gets a refusal, not a silently pinned camera. - "camerayawmovespeed" | "camerapitchmovespeed" => { - if !CAMERA_SPEED_RANGE.contains(&v) { - warn!( - "cvar {name}: value out of range ({} - {}) — ignored", - CAMERA_SPEED_RANGE.start(), - CAMERA_SPEED_RANGE.end() - ); - return true; - } - if key == "camerayawmovespeed" { - knobs.look.yaw_speed = v; - } else { - knobs.look.pitch_speed = v; - } - } - "combatdamage" => knobs.damage_text.combat_damage = v != 0.0, - "petmeleedamage" => knobs.damage_text.pet_melee = v != 0.0, - "petspelldamage" => knobs.damage_text.pet_spell = v != 0.0, - "combatlogperiodicspells" => knobs.log_periodic.0 = v != 0.0, - // The combat log's eight display ranges (yards, the CVar's float field). One arm for all - // of them: `CombatLogRanges::set` walks the class table through `UnitClass::range_cvar`, - // so the seven names live in exactly one place and this arm cannot drift from them. - _ if knobs.combat_ranges.set(name, v) => {} - "autolootdefault" => knobs.loot.auto_loot = v != 0.0, - "unitnameplayer" => knobs.names.player = v != 0.0, - "unitnamenpc" => knobs.names.npc = v != 0.0, - "unitnameown" => knobs.names.own = v != 0.0, - "unitnameplayerguild" => knobs.names.player_guild = v != 0.0, - // The camera options (2149). The three numeric ones take the value straight: the - // reference's own validator on them is `0x50b330`'s range REFUSAL, which lives in - // `benilla_ui`'s `SetCVar` path, not here. - "camerapivot" => knobs.camera_opts.pivot = v != 0.0, - "camerawatercollision" => knobs.camera_opts.water_collision = v != 0.0, - "camerapivotdxmax" => knobs.camera_opts.pivot_dx_max = v, - "camerapivotdymin" => knobs.camera_opts.pivot_dy_min = v, - "cameratargetsmoothspeed" => knobs.camera_opts.target_smooth_speed = v, - "cameraterraintilt" => knobs.camera_opts.terrain_tilt = v != 0.0, - "cameragroundsmoothspeed" => knobs.camera_opts.ground_smooth_speed = v, - "cameraterraintilttimemin" => knobs.camera_opts.tilt_time_min = v, - "cameraterraintilttimemax" => knobs.camera_opts.tilt_time_max = v, - "camerabobbing" => knobs.camera_opts.bobbing = v != 0.0, - "camerabobbinglramplitude" => knobs.camera_opts.bob_lr_amplitude = v, - "camerabobbingudamplitude" => knobs.camera_opts.bob_ud_amplitude = v, - "camerabobbingfrequency" => knobs.camera_opts.bob_frequency = v, - "camerabobbingsmoothspeed" => knobs.camera_opts.bob_smooth_speed = v, - // The two V-plate toggles — the bitmask's two bits, flags like every other checkbox. - // Lowercased here like every arm; `VPlateMode`'s consts carry the registered spelling. - "nameplateshowenemies" => knobs.plates.enemies = v != 0.0, - "nameplateshowfriends" => knobs.plates.friends = v != 0.0, - // Two CVars with no HOST knob, because their consumers are Lua (1140, B230). Known — so - // the caller dirties the config and the value persists — with nothing to apply this side. - "statusbartext" | "ubertooltips" => {} - // The two bubble switches (1139) — flags, like every other pair here. - "showgametips" => knobs.game_tip.show = v != 0.0, - // The cursor, not a preference — a hand-edited or downgraded value lands here verbatim and - // `game_tip::raise` clamps it, which is the reference's own tolerance (`0x46b682`). - "gametip" => knobs.game_tip.next = v as i64, - "profanityfilter" => knobs.text_filter.profanity = v != 0.0, - "spamfilter" => knobs.text_filter.spam = v != 0.0, - "chatbubbles" => knobs.bubbles.all = v != 0.0, - "chatbubblesparty" => knobs.bubbles.party = v != 0.0, - // The loot-roll detail switch (1589) — a flag over the roll-line composer's two shapes. - "showlootspam" => knobs.loot.show_loot_spam = v != 0.0, - // Guild Member Alert (1589) — conjunct 2 of the sign-on/sign-off line's condition. - "guildmembernotify" => knobs.guild_notify.0 = v != 0.0, - "blocktrades" => knobs.block_trades.0 = v != 0.0, - // The panel's 0/1/2 lands as the density multiplier ×1/×2/×3; the clamp is the 1.12 - // slider's own range (an off-grid hand-edit rides between stops, like every slider). - "worlddetail" => knobs.clutter.density = v.clamp(0.0, 2.0) + 1.0, - // The SAME knob in the reference's own cells-per-chunk (2151), with the reference's own - // `[1, 256]` clamp rather than the stop's — `ClutterConfig::set_frill_density` carries - // both, and `terrain_stream::rescatter_clutter` re-scatters the loaded tiles off the - // resulting density change exactly as it does for the row above (0992's setter law, which - // is the callback's own chunk rebuild). - "frilldensity" => knobs.clutter.set_frill_density(v), - // Weather Intensity, the panel's 0..3 step 1 (2181). The reference's callback is - // `0x67b870`, a jump table (`0x67b8e8`) mapping 0/1/2/3 onto the quality cells - // {0.1, 0.33, 0.66, 1.0} in `[0x8680ec]` (wow-re - // `cvar/scratch/graphics-cost-cvar-census.md` §4). What that table does with an - // off-grid int is NOT carved, so the clamp here is this table's own standing - // posture rather than a fidelity claim — and it costs nothing either way, because - // `WeatherState::density_gain` already `.min(3)`s its own index. - "weatherdensity" => knobs.weather.weather_density = v.trunc().clamp(0.0, 3.0) as u8, - // Brightness (2182). The clamp is OURS and the reference has none — the reason it - // costs one is on [`crate::ui_gamma::GAMMA_RANGE`], and nothing a player can reach - // from the panel meets it. - "gamma" => { - knobs.display_gamma.0 = v.clamp( - *crate::ui_gamma::GAMMA_RANGE.start(), - *crate::ui_gamma::GAMMA_RANGE.end(), - ) - } - // The two zoom indices (1131) clamp exactly like the client's `set_zoom` (`0x6daa10`: - // clamp at 5) — the widget clamps again on the way in, so a hand-edited level lands - // in range whichever path it takes. - "minimapzoom" => knobs.minimap.outdoor = zoom_index(v), - "minimapinsidezoom" => knobs.minimap.inside = zoom_index(v), - // The addon version gate (1292): no host knob — the load walk reads the persisted value - // and the gate reads the live table — but a KNOWN key, so a toggle dirties the config - // and persists (the statusBarText posture). - "checkaddonversion" => {} - // The remembered character row (1622) — same posture again: the live value is the select - // screen's own, which writes this key rather than reading it back. Known, so entering the - // world dirties the config and the memory survives to the next launch. - "lastcharacterindex" => {} - // Vertical Sync — a flag like every other checkbox here. `video::apply_present_mode` - // watches the value and pushes it to the window; nothing else reads it. - "gxvsync" => knobs.video.vsync = v != 0.0, - // Display mode (1627) — a flag like every other checkbox here, and the reference's own - // polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode` - // watches the value and pushes it to the window; nothing else reads it. - "gxwindow" => knobs.video.display = crate::video::display_from_flag(v), - // The body panes' half-rate render (1444) — a flag like every other checkbox here. - "boothhalfrate" => knobs.pane_rate.half = v != 0.0, - // Render scale (1639). Clamped at the knob's edge like every other numeric row; the - // backdrop re-sizes on the next frame and the world camera's target factor follows it - // in the same pass, which is what keeps the pick rays where they were. - "renderscale" => { - knobs.render_scale.0 = v.clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end()); - } - // The FPS journal switch (2008): a flag, the client's int-parse + `!= 0`. The journal - // system reads the knob every frame, so the file opens on the next second and closes - // the second it is turned off. - "fpsjournal" => knobs.fps_journal.0 = v != 0.0, - // Multisampling (1629) — the reference's own `atoi`-then-clamp `[1, 16]` at `0x63b250`. - // Writing the knob live is faithful, not a bug: the CVar holds the PENDING value (latched), - // and nothing reads this resource after the world camera's spawn. - "gxmultisample" => { - // TWO ceilings, and the second one was missing until 1643. The reference's own - // `atoi`-then-clamp `[1, 16]` comes first; then the DEVICE's, because a count this - // GPU does not offer is not a setting that degrades — it is a wgpu validation error - // that kills the render thread on frame one ("Sample count 8 is not supported by - // format Rgba16Float on this device", 2026-08-26). - // - // `MsaaSupportPlugin::finish` already clamped, but it runs once, before the first - // update — so it saw `MsaaSetting::default()` and never the value `load_config` was - // about to fold in from `config.toml`. A config written on a machine that offers 8x - // and opened on one that stops at 4 therefore reached the camera untouched. Clamping - // at the WRITE covers every writer there is: the file, a Lua `SetCVar`, the dropdown, - // and the Defaults button. - let asked = (v as u32).clamp(*MSAA_RANGE.start(), *MSAA_RANGE.end()); - let granted = knobs.msaa_formats.clamp(asked); - if granted != asked { - // At `warn`, the same posture as the seed clamp: the player asked for something - // and did not get it, and this is the only place that fact exists. - warn!( - "cvar {name}: this GPU does not offer {asked}x multisampling — using {granted}x" - ); - } - knobs.msaa.samples = granted; - } - // The filter policy's two halves. Both write the PENDING value — latched, like - // `gxMultisample`: the process policy is published once at the end of `load_config` and - // nothing reads this resource afterwards. `anisotropic` takes the reference's own - // parse-then-clamp `[1, 16]` (`0x689110`); `trilinear` is a flag like every other. - "trilinear" => knobs.tex_filter.trilinear = v != 0.0, - "anisotropic" => { - knobs.tex_filter.aniso = (v as u32).clamp( - *benilla_assets::ANISO_RANGE.start(), - *benilla_assets::ANISO_RANGE.end(), - ) - } - _ => return false, - } - true -} - -/// Freeze the texture filter policy for the process, and say what it resolved to. -/// -/// **A separate system, chained after [`load_config`], deliberately.** `load_config` returns early -/// on an absent or malformed file, and the policy has to be published on every one of those paths: -/// the sampler lanes are an async `AssetLoader` and a set of ordinary systems, none of which can -/// read a resource the others own, so a run that never published would be reading -/// [`benilla_assets::tex_filter`]'s fallback while a player's `config.toml` said otherwise. -/// -/// The log line is not decoration — it is the same reasoning as `video::log_display_session` -/// (1627). Every filtering report this client will get comes from a machine nobody here can run, -/// and "which mode was that run actually in" must be readable off the log a player pastes rather -/// than reasoned about. -fn publish_filter_policy(filter: Res) { - benilla_assets::publish_tex_filter(*filter); - let mode = filter.mode(); - let name = match mode { - 3 => "bilinear + nearest-mip select, aniso off", - 4 => "trilinear, aniso off", - _ => "trilinear + aniso", - }; - info!( - "texture filter: mode {mode} ({name}) — trilinear={} anisotropic={}", - u8::from(filter.trilinear), - filter.aniso - ); -} - -/// A stored minimap zoom level → a valid index: truncate to int and clamp into -/// `[0, MINIMAP_ZOOM_LEVELS)`, the client's own `set_zoom` clamp. -fn zoom_index(v: f32) -> u8 { - v.clamp(0.0, f32::from(MINIMAP_ZOOM_LEVELS - 1)) as u8 -} - -/// The two registered spellings of `ClutterConfig::density`, lowercased (2151) — `WorldDetail`'s -/// panel stop and `frillDensity`'s cells-per-chunk. -/// -/// Named as a **pair**, because that is the thing about them that is easy to get wrong: anything -/// which takes the knob for the session has to take *both* keys. `$WOW_CLUTTER_DENSITY` marked -/// only `worlddetail` for exactly as long as it was the only spelling, and the moment the second -/// row landed that would have let an A/B lever ride into `config.toml` through the other name and -/// pin itself on every later launch. -const CLUTTER_DENSITY_CVARS: [&str; 2] = ["worlddetail", "frilldensity"]; - -/// Startup: read `benilla-config/config.toml` (absent file = all defaults, not an error) and apply it -/// to the knob resources — except the keys this session owns rather than the player -/// ([`CvarPersist::session_owned`]): an env lever's resource has already read the variable in its -/// `Default`, and `gxApi` is the machine's own. The VM does not exist yet; [`sync_cvars`] seeds -/// the table when it does. -fn load_config(mut persist: ResMut, mut params: KnobParams) { - let mut knobs = params.knobs(); - if std::env::var_os("WOW_UI_SCALE").is_some() { - persist.session_owned.insert("uiscale".into()); - } - if std::env::var_os("WOW_FARCLIP").is_some() { - persist.session_owned.insert("farclip".into()); + if set("WOW_FARCLIP") { + let v = world.get_resource::(); + out.push(("farclip", v.map(|s| s.farclip.to_string()))); } // The clutter A/B env drives the same knob WorldDetail lands on — same session-only law, over - // BOTH of that knob's spellings ([`CLUTTER_DENSITY_CVARS`]). - if std::env::var_os("WOW_CLUTTER_DENSITY").is_some() { - for key in CLUTTER_DENSITY_CVARS { - persist.session_owned.insert(key.into()); - } + // BOTH of that knob's spellings ([`CLUTTER_DENSITY_CVARS`]). An off-grid multiplier seeds + // off-grid honestly: the dropdown shows the raw number and checks nothing (the 0959 + // out-of-range posture, dropdown-flavored). + if set("WOW_CLUTTER_DENSITY") { + let v = world.get_resource::(); + out.push(("WorldDetail", v.map(|c| (c.density - 1.0).to_string()))); + out.push(("frillDensity", v.map(|c| c.frill_density().to_string()))); } // `$WOW_NOVSYNC=1` is the measurement uncap: session-only, exactly like the taste-iteration // overrides above. Pinning it into the config would make an instrument run sticky. if crate::video::novsync_env() { - persist.session_owned.insert("gxvsync".into()); + let v = world.get_resource::(); + out.push(("gxVSync", v.map(|c| flag(c.vsync)))); } // The filter policy's A/B levers, under the same law: pricing mode 3 against mode 5 on one // machine in one session is exactly what these are for, and a value that stuck in // `config.toml` would silently denominate every later reading. - if std::env::var_os("WOW_TRILINEAR").is_some() { - persist.session_owned.insert("trilinear".into()); + let tex = world.get_resource::(); + if set("WOW_TRILINEAR") { + out.push(("trilinear", tex.map(|t| flag(t.trilinear)))); } - if std::env::var_os("WOW_ANISO").is_some() { - persist.session_owned.insert("anisotropic".into()); + if set("WOW_ANISO") { + out.push(("anisotropic", tex.map(|t| t.aniso.to_string()))); } // `$WOW_WIN`, a capture scenario, or any instrumented run owns the window's geometry for the // session (decision 1627), so the two CVars that would otherwise move it mid-run are - // session-only under exactly the same law as the four above. + // session-only under exactly the same law as the levers above. if crate::video::windowed_env() { - persist.session_owned.insert("gxwindow".into()); - persist.session_owned.insert("gxresolution".into()); + let v = world.get_resource::(); + out.push(( + "gxWindow", + v.map(|c| flag(c.display == crate::video::DisplayMode::Windowed)), + )); + out.push(( + "gxResolution", + v.map(|c| format!("{}x{}", c.windowed.x, c.windowed.y)), + )); } - // `$WOW_MSAA` is the multisampling A/B lever (1629), session-only under the same law as every - // override above: a value pinned into the file would make a measurement sticky across - // relaunches. - if std::env::var_os("WOW_MSAA").is_some() { - persist.session_owned.insert("gxmultisample".into()); + // `$WOW_MSAA` is the multisampling A/B lever (1629); `$WOW_RENDER_SCALE` the render-scale one + // (1639), doubly session-only because it is also the supersampling instrument this machine + // prices pixels with, and an instrument run that pinned 4× into the file would come back at + // 4× the next time the client opened. + if set("WOW_MSAA") { + let v = world.get_resource::(); + out.push(("gxMultisample", v.map(|m| m.samples.to_string()))); } - // `$WOW_RENDER_SCALE` is the render-scale A/B lever (1639), and doubly session-only: it is - // also the supersampling instrument this machine prices pixels with, and an instrument run - // that pinned 4× into the file would come back at 4× the next time the client opened. - if std::env::var_os("WOW_RENDER_SCALE").is_some() { - persist.session_owned.insert("renderscale".into()); + if set("WOW_RENDER_SCALE") { + let v = world.get_resource::(); + out.push(("renderScale", v.map(|r| r.0.to_string()))); } // `$WOW_HOST` is the realmlist for the session (1667) — every probe, smoke run and harness leg // sets it, and a value pinned into the file would silently repoint the player's client at // whatever a test dialed. `Realmlist::default()` has already taken it; this keeps it off disk. - if std::env::var_os("WOW_HOST").is_some() { - persist.session_owned.insert("realmlist".into()); + if set("WOW_HOST") { + let v = world.get_resource::(); + out.push(( + crate::realmlist::CVAR_REALMLIST, + v.map(|r| r.address().to_string()), + )); } - // The one member with no env var behind it (2151): `gxApi` reports the render adapter's - // backend, which is a fact about the machine rather than a setting the player chose. It is - // pushed live by `sync_cvars` on every launch, so persisting it could only ever write a name - // that the next launch overwrites — or, worse, a stale one that outlives the GPU it described. - persist.session_owned.insert("gxapi".into()); - let cvars = match stored_config() { - StoredConfig::Absent => return, // no file, hermetic capture, or no install - StoredConfig::Bad(msg) => { - // A malformed file is preserved, not clobbered: nothing loads, but nothing saves - // over it either until a change actually happens — and the warn names the file. - warn!("{msg}"); - return; + out +} + +/// Startup: mark what the session owns, read `benilla-config/config.toml` (absent file = all +/// defaults, not an error) into the registry, and **fire the observers here and now** — this is +/// exclusive so that everything ordered after [`CvarLoad`] finds its knob already written, the +/// way the reference's subsystems find their callback already run by the time they read a +/// record. The VM does not exist yet; [`sync_cvars`] seeds its mirror when it does. +fn load_config(world: &mut World) { + let session = session_values(world); + let stored = stored_config(); + let events = { + let mut cvars = world.resource_mut::(); + for (name, value) in session { + cvars.own_for_session(name, value.as_deref()); } - StoredConfig::Table(t) => t, + // The one member with no env var behind it (2151): `gxApi` reports the render adapter's + // backend, which is a fact about the machine rather than a setting the player chose. It + // is pushed live by [`sync_cvars`] once the adapter exists, so persisting it could only + // ever write a name that the next launch overwrites — or, worse, a stale one that + // outlives the GPU it described. + cvars.own_for_session("gxApi", None); + match stored { + StoredConfig::Absent => {} // no file, hermetic capture, or no install + StoredConfig::Bad(msg) => { + // A malformed file is preserved, not clobbered: nothing loads, but nothing + // saves over it either until a change actually happens — and the warn names + // the file. + warn!("{msg}"); + } + StoredConfig::Table(table) => cvars.load_file(table), + } + cvars.take_events() }; - let known: HashSet = REGISTERED - .iter() - .map(|r| r.name.to_ascii_lowercase()) - .collect(); - for (name, value) in &cvars { - let key = name.to_ascii_lowercase(); - if !known.contains(&key) { - warn!("config: unknown cvar '{name}' — preserved, not applied"); - continue; - } - if persist.session_owned.contains(&key) { - info!("config: {name} is owned by this session, not the file (file value kept)"); - continue; - } - apply_to_knobs(name, value, &mut knobs); + for event in events { + world.trigger(event); } - persist.file = cvars; } /// What the one read of `config.toml` found. @@ -1845,8 +1839,8 @@ fn stored_config() -> StoredConfig { /// One CVar as `config.toml` holds it, matched case-insensitively — **before the `App` exists** /// (decision 1627). /// -/// Every other consumer wants [`CvarPersist::stored`], which answers from the same values once -/// they are a resource and stays current across a VM replacement (1291). This one exists for the +/// Every other consumer wants [`Cvars::get`], which answers from the registry once it is a +/// resource and stays current across a VM replacement (1291). This one exists for the /// single caller that cannot wait for a resource: the primary window has to be *built* with its /// display mode already resolved. pub(crate) fn boot_cvar(name: &str) -> Option { @@ -1859,79 +1853,83 @@ pub(crate) fn boot_cvar(name: &str) -> Option { } } -/// Per frame: seed the VM's table once it exists (registered set + the RESOLVED session values, -/// so `GetCVar` reflects env overrides and the loaded config alike), then drain Lua `SetCVar` -/// changes into the knob resources and mark the config dirty. -fn sync_cvars( +/// Per frame: seed the VM's mirror once it exists (the registered set at its live values, so +/// `GetCVar` reflects env overrides and the loaded config alike), drain the VM's registrations +/// and writes into the registry, push the registry's own writes into the mirror, and trigger +/// every accepted move for its observers. +pub(crate) fn sync_cvars( script: Option>, - mut persist: ResMut, - mut params: KnobParams, + mut cvars: ResMut, adapter: Option>, + msaa_formats: Option>, + mut seeded: Local>, + mut commands: Commands, ) { + // **The machine's, not the player's** (2151): the live render backend, so `GetCVar` and + // pfUI's system tooltip answer what this run is actually on. Absent in a headless app with + // no renderer, where the registered `""` stands and says so. + if let Some(adapter) = adapter.as_deref() { + let backend = adapter.backend.to_str(); + if cvars.get("gxApi") != Some(backend) { + cvars.own_for_session("gxApi", Some(backend)); + } + } let Some(mut script) = script else { + // Nothing to mirror into; a later VM is seeded from the table, which carries it all. + if cvars.has_outbox() { + cvars.take_outbox(); + } + if cvars.has_events() { + for event in cvars.take_events() { + commands.trigger(event); + } + } return; }; - if persist.registered.claim(&script) { - // Read-only borrows for the seed: field access through `ResMut`'s `Deref` flags nothing, - // which is the half of 0992's change-detection trap this system has to keep. - let KnobParams { - camera_opts, - sound, - scale, - view, - look, - click, - loot, - names, - plates, - clutter, - weather, - display_gamma, - minimap, - bubbles, - zoom, - follow, - video, - render_scale, - pane_rate, - guild_notify, - block_trades, - auto_self_cast, - text_filter, - game_tip, - msaa, - msaa_formats, - tex_filter, - realmlist, - fps_journal, - assist_attack, - combat_ranges, - damage_text, - log_periodic, - } = ¶ms; - // The config file's values go in FIRST (decision 1291): registration — ours below, or an - // addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a - // knobless CVar (`statusBarText`) and an addon-declared one across a VM replacement; the - // knob-derived session rows below still win for every key a host knob backs, and an - // env-overridden key keeps its env value the same way (its knob carries it). - script.set_cvar_saved_base(persist.saved_base().collect::>()); - script.register_cvars(registered_pairs()); + // **The VM's writes first, the seed second.** A fresh VM loads the whole interface before + // this system's first turn against it, and an addon's `SetCVar` at load is already in the + // queue by then; seeding first would overwrite the mirror with the registry's older value + // and then apply the write to the registry alone, leaving `GetCVar` a frame behind for the + // rest of the session. Registrations before writes: an addon declares a row and sets it in + // the same breath. + let registrations = script.take_cvar_registrations(); + let changes = script.take_cvar_changes(); + if !registrations.is_empty() || !changes.is_empty() { + for (name, default) in registrations { + cvars.learn_addon_row(&name, &default); + } + for (name, value) in changes { + cvars.set_from_vm(&name, &value); + } + } + if seeded.claim(&script) { + // The file's unclaimed entries go in FIRST (decision 1291): an addon's `RegisterCVar` + // later starts its key at the saved value. Then the table itself, at its live values. + script.set_cvar_saved_base(cvars.orphans()); + script.seed_cvars(cvars.vm_seed()); + if cvars.has_outbox() { + cvars.take_outbox(); // the seed just carried everything + } // The Video dropdown's menu — what this device actually accepts, enumerated once at // `finish()` by `view::MsaaSupportPlugin` (decision 1631) and handed over whole. Pushed // here rather than owned by the VM because the list is a fact about the render adapter, // which `benilla-ui` has no way to ask and should not grow one. script.set_multisample_formats( msaa_formats - .formats - .iter() - .map( - |&(color_bits, depth_bits, samples)| benilla_ui::script::MultisampleFormat { - color_bits, - depth_bits, - samples, - }, - ) - .collect(), + .as_deref() + .map(|f| { + f.formats + .iter() + .map(|&(color_bits, depth_bits, samples)| { + benilla_ui::script::MultisampleFormat { + color_bits, + depth_bits, + samples, + } + }) + .collect() + }) + .unwrap_or_default(), ); // **What `GetVideoCaps` answers with** (decision 2177) — the seven values the stock video // window's `OptionsFrame_Load` destructures. Pushed beside the multisample list because it @@ -1964,299 +1962,61 @@ fn sync_cvars( max_anisotropy: *benilla_assets::ANISO_RANGE.end(), hardware_cursor: true, }); - let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 87] = [ - ("MasterVolume", sound.master.to_string()), - ("SoundVolume", sound.sfx.to_string()), - ("MusicVolume", sound.music.to_string()), - ("AmbienceVolume", sound.ambience.to_string()), - ("MasterSoundEffects", flag(sound.enabled)), - ("EnableMusic", flag(sound.music_enabled)), - ("EnableAmbience", flag(sound.ambience_enabled)), - ("EnableErrorSpeech", flag(sound.error_speech)), - ( - "Sound_EnableSoundWhenGameIsInBG", - flag(sound.background_sound), - ), - ("SoundReverb", flag(sound.reverb)), - ("SoundOutputLimiter", flag(sound.limiter)), - ( - "SoundListenerAtCharacter", - flag(sound.listener_at_character), - ), - ("EmoteSounds", flag(sound.emote_sounds)), - ("SoundZoneMusicNoDelay", flag(sound.zone_music_no_delay)), - ("uiScale", scale.0.to_string()), - ("farclip", view.farclip.to_string()), - ("nearclip", view.nearclip.to_string()), - ("deselectOnClick", flag(click.deselect_on_click)), - ("autoSelfCast", flag(auto_self_cast.0)), - ("assistAttack", flag(assist_attack.0)), - ("mouseInvertPitch", flag(look.invert_pitch)), - ("mousespeed", look.sensitivity.to_string()), - ("cameraYawMoveSpeed", look.yaw_speed.to_string()), - ("cameraPitchMoveSpeed", look.pitch_speed.to_string()), - ("cameraDistanceMaxFactor", zoom.factor().to_string()), - ("cameraSmoothStyle", follow.style.cvar().to_string()), - ( - "cameraSmoothTrackingStyle", - follow.tracking_style.cvar().to_string(), - ), - ("cameraYawSmoothSpeed", follow.yaw_speed.to_string()), - ("cameraPivot", flag(camera_opts.pivot)), - ("cameraPivotDXMax", camera_opts.pivot_dx_max.to_string()), - ("cameraPivotDYMin", camera_opts.pivot_dy_min.to_string()), - ( - "cameraTargetSmoothSpeed", - camera_opts.target_smooth_speed.to_string(), - ), - ("cameraWaterCollision", flag(camera_opts.water_collision)), - ("cameraTerrainTilt", flag(camera_opts.terrain_tilt)), - ( - "cameraGroundSmoothSpeed", - camera_opts.ground_smooth_speed.to_string(), - ), - ( - "cameraTerrainTiltTimeMin", - camera_opts.tilt_time_min.to_string(), - ), - ( - "cameraTerrainTiltTimeMax", - camera_opts.tilt_time_max.to_string(), - ), - ("cameraBobbing", flag(camera_opts.bobbing)), - ( - "cameraBobbingLRAmplitude", - camera_opts.bob_lr_amplitude.to_string(), - ), - ( - "cameraBobbingUDAmplitude", - camera_opts.bob_ud_amplitude.to_string(), - ), - ( - "cameraBobbingFrequency", - camera_opts.bob_frequency.to_string(), - ), - ( - "cameraBobbingSmoothSpeed", - camera_opts.bob_smooth_speed.to_string(), - ), - ("autoLootDefault", flag(loot.auto_loot)), - ("showLootSpam", flag(loot.show_loot_spam)), - ("guildMemberNotify", flag(guild_notify.0)), - ("BlockTrades", flag(block_trades.0)), - ("UnitNamePlayer", flag(names.player)), - ("UnitNameNPC", flag(names.npc)), - ("UnitNameOwn", flag(names.own)), - ("UnitNamePlayerGuild", flag(names.player_guild)), - (crate::vplates::CVAR_ENEMIES, flag(plates.enemies)), - (crate::vplates::CVAR_FRIENDS, flag(plates.friends)), - // The session density on the panel scale (×1..×3 → 0..2). An env-driven off-grid - // multiplier seeds off-grid honestly — the dropdown shows the raw number, checks - // nothing (the 0959 out-of-range posture, dropdown-flavored). A console - // `frillDensity` past the top stop reads off-grid here for the same reason, which is - // the reference's own inconsistency between its two writers (2151). - ("WorldDetail", (clutter.density - 1.0).to_string()), - // …and the same density in the reference's own cells-per-chunk (2151). - ("frillDensity", clutter.frill_density().to_string()), - ("weatherDensity", weather.weather_density.to_string()), - // Six decimals, matching `SetGamma`'s own `"%f"` — see the row's comment. - ("gamma", format!("{:.6}", display_gamma.0)), - ("ChatBubbles", flag(bubbles.all)), - ("ChatBubblesParty", flag(bubbles.party)), - ("profanityFilter", flag(text_filter.profanity)), - ("spamFilter", flag(text_filter.spam)), - ("showGameTips", flag(game_tip.show)), - ("gameTip", game_tip.next.to_string()), - ("minimapZoom", minimap.outdoor.to_string()), - ("minimapInsideZoom", minimap.inside.to_string()), - // **The machine's, not the player's** (2151): the live render backend, so `GetCVar` - // and pfUI's system tooltip answer what this run is actually on. `None` only in a - // headless app with no renderer, where the registered `""` stands and says so. - ( - "gxApi", - adapter - .as_ref() - .map_or_else(String::new, |a| a.backend.to_str().to_string()), - ), - ("gxVSync", flag(video.vsync)), - // The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state. - ( - "gxWindow", - flag(video.display == crate::video::DisplayMode::Windowed), - ), - // The one string-valued row, composed in the reference's spelling. - ( - "gxResolution", - format!("{}x{}", video.windowed.x, video.windowed.y), - ), - ("boothHalfRate", flag(pane_rate.half)), - ("renderScale", render_scale.0.to_string()), - ("gxMultisample", msaa.samples.to_string()), - ("trilinear", flag(tex_filter.trilinear)), - ("anisotropic", tex_filter.aniso.to_string()), - ("fpsJournal", flag(fps_journal.0)), - // The other string-valued row (1667): what the next logon attempt will actually dial, - // including a `$WOW_HOST` the player never typed. - ( - crate::realmlist::CVAR_REALMLIST, - realmlist.address().to_string(), - ), - // The combat log's eight display ranges, off the live table — written out one class at - // a time rather than composed in a loop, because this array is the readable census of - // what a session's `GetCVar` answers and a loop would hide eight rows inside one. - ( - "CombatLogRangeParty", - combat_ranges.class(CombatClass::Party).to_string(), - ), - ( - "CombatLogRangePartyPet", - combat_ranges.class(CombatClass::PartyPet).to_string(), - ), - ( - "CombatLogRangeFriendlyPlayers", - combat_ranges.class(CombatClass::FriendlyPlayer).to_string(), - ), - ( - "CombatLogRangeFriendlyPlayersPets", - combat_ranges.class(CombatClass::FriendlyPet).to_string(), - ), - ( - "CombatLogRangeHostilePlayers", - combat_ranges.class(CombatClass::HostilePlayer).to_string(), - ), - ( - "CombatLogRangeHostilePlayersPets", - combat_ranges.class(CombatClass::HostilePet).to_string(), - ), - ( - "CombatLogRangeCreature", - combat_ranges.class(CombatClass::Creature).to_string(), - ), - ( - crate::ui_chat::combat::DEATH_LOG_RANGE_CVAR, - combat_ranges.death().to_string(), - ), - ("CombatDamage", flag(damage_text.combat_damage)), - ("PetMeleeDamage", flag(damage_text.pet_melee)), - ("PetSpellDamage", flag(damage_text.pet_spell)), - ( - crate::ui_chat::combat::LOG_PERIODIC_CVAR, - flag(log_periodic.0), - ), - ]; - for (name, value) in session { - script.set_cvar_host(name, &value); + } + if cvars.has_outbox() { + for (name, value) in cvars.take_outbox() { + script.set_cvar_host(&name, &value); } } - // Take the changes BEFORE touching the knobs: constructing `Knobs` deref-muts every knob - // resource, which trips Bevy change detection even when nothing is written — and the - // clutter re-scatter is downstream of exactly that signal staying honest (0992). - let changes = script.take_cvar_changes(); - if changes.is_empty() { - return; - } - let mut knobs = params.knobs(); - for (name, value) in changes { - if apply_to_knobs(&name, &value, &mut knobs) { - persist.dirty = true; - persist.last_change = Some(Instant::now()); + if cvars.has_events() { + for event in cvars.take_events() { + commands.trigger(event); } } } -/// **A HOST-side CVar write that persists** — the counterpart to a Lua `SetCVar`, for the one CVar -/// the engine itself owns: `gameTip`, the loading screen's cursor (2077). -/// -/// It has to go through the VM's table rather than through the knob alone, because -/// [`save_config`] composes the file from `cvars_snapshot()` — the knob is only ever *seeded* into -/// a VM at claim time, so a host write that stops at the knob is invisible to the file and the -/// cursor resets every launch. Marking dirty here is what arms the debounced save. -pub(crate) fn write_host_cvar( - script: &mut UiScript, - persist: &mut CvarPersist, - name: &str, - value: &str, -) { - script.set_cvar_host(name, value); - persist.dirty = true; - persist.last_change = Some(Instant::now()); -} - -/// Fold the dying VM's CVar table into the persist state — the session edge's half of decision +/// Fold the dying VM's last writes into the registry — the session edge's half of decision /// 1291's bridge (the seed in [`sync_cvars`] is the other). Called from /// [`crate::ui_script::end_ui_session`] **after** the shutdown events (an addon's /// `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference that write lands in an /// engine-side store that survives) and **before** the VM is replaced. /// -/// Two steps, both about the writes the per-frame sync never got to see: -/// 1. drain the dying VM's change queue into the host knobs — a `SetCVar` in the final frame -/// would otherwise be overwritten by the stale knob when the next VM's seed runs; -/// 2. fold the table into `persist.file` with the same compose the saver uses, so the next VM's -/// saved base — and the next save — both start from what the player actually set. -/// -/// `dirty` is left alone: if nothing changed, the fold is an identity; if something did, the -/// change that did it already marked the config dirty. +/// The registry IS the store that survives, so there is nothing to copy back: only the writes +/// the per-frame sync never got to see — a `SetCVar` in the final frame would otherwise be +/// overwritten by the stale value when the next VM's seed runs. The observers fire here, so the +/// knobs are current before the next VM is even built. pub(crate) fn fold_dying_vm_cvars(world: &mut World) { - // A world with no persist state has no file to bridge — a test world or a stripped scenario - // that never added the plugin. It is checked up front because the knob set below is fetched - // NON-optionally, and the two facts are one: any world carrying `CvarPersist` carries every - // knob too (the plugin's own `load_config`/`sync_cvars` take them the same way, and would - // have panicked at startup otherwise). - if !world.contains_resource::() { + // A world with no registry has no file to bridge — a test world or a stripped scenario that + // never added the plugin. + if !world.contains_resource::() { return; } - let mut state: bevy::ecs::system::SystemState<( - Option>, - ResMut, - KnobParams, - )> = bevy::ecs::system::SystemState::new(world); - let (script, mut persist, mut params) = state.get_mut(world); - let Some(mut script) = script else { - return; + let (registrations, changes) = { + let Some(mut script) = world.get_non_send_resource_mut::() else { + return; + }; + (script.take_cvar_registrations(), script.take_cvar_changes()) }; - let changes = script.take_cvar_changes(); - if !changes.is_empty() { - let mut knobs = params.knobs(); + let events = { + let mut cvars = world.resource_mut::(); + for (name, default) in registrations { + cvars.learn_addon_row(&name, &default); + } for (name, value) in changes { - if apply_to_knobs(&name, &value, &mut knobs) { - persist.dirty = true; - persist.last_change = Some(Instant::now()); - } + cvars.set_from_vm(&name, &value); } - } - let snapshot = script.cvars_snapshot(); - if snapshot.is_empty() { - return; // a VM that never registered (a capture) has nothing to say about the file - } - persist.file = compose_file(&persist.file, &persist.session_owned, &snapshot); -} - -/// Compose the file to save: the previous file as the merge base, every registered var that -/// moved off its default written, every one back at its default removed — session-owned keys -/// untouched (that value is the env's or the machine's, not the player's). -fn compose_file( - previous: &BTreeMap, - session_owned: &HashSet, - snapshot: &[(String, String, String)], -) -> BTreeMap { - let mut out = previous.clone(); - for (name, value, default) in snapshot { - let key = name.to_ascii_lowercase(); - if session_owned.contains(&key) { - continue; + if cvars.has_outbox() { + cvars.take_outbox(); // the VM this was for is going away } - // Match any existing entry case-insensitively so a hand-edited spelling doesn't fork. - let existing = out.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned(); - if value == default { - if let Some(k) = existing { - out.remove(&k); - } + if cvars.has_events() { + cvars.take_events() } else { - out.insert(existing.unwrap_or_else(|| name.clone()), value.clone()); + Vec::new() } + }; + for event in events { + world.trigger(event); } - out } /// The file's header comment — where these values come from and where the law lives. @@ -2265,59 +2025,86 @@ const HEADER: &str = "\ # Managed by the client; hand edits are read on next launch and preserved on save. "; -/// Dirty + one quiet second (or the app exiting) → rewrite `config.toml` atomically. -fn save_config( - script: Option>, - mut persist: ResMut, - mut exits: MessageReader, -) { +/// Dirty + one quiet second (or the app exiting) → rewrite `config.toml` atomically, from the +/// registry — the store, not the VM's mirror, so a session with no VM at all (the glue screens, +/// a headless run) saves exactly what it changed. +fn save_config(mut cvars: ResMut, mut exits: MessageReader) { let exiting = exits.read().next().is_some(); - if !persist.dirty { + if !cvars.dirty { return; } - let quiet = persist - .last_change - .is_none_or(|t| t.elapsed() >= SAVE_QUIET); + let quiet = cvars.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET); if !(quiet || exiting) { return; } - let Some(script) = script else { return }; let Some(path) = crate::local_state::config_path() else { - persist.dirty = false; // hermetic/session-only: nothing to write, stop retrying + cvars.dirty = false; // hermetic/session-only: nothing to write, stop retrying return; }; - let snapshot = script.cvars_snapshot(); - // **An empty table is not a player who cleared their settings.** The file is composed from the - // VM's live table, so a VM whose table was never registered would compose the player's - // `config.toml` back out *stripped* — a silent, irreversible loss of everything they had set. - // The seed above is what keeps that from happening; this is the floor under it, because the - // failure is one-way and the next regression in that seed must not be able to reach the disk. - if snapshot.is_empty() { - warn!("config: the VM has no registered cvars — refusing to compose the file from nothing"); - persist.dirty = false; // nothing to save, and retrying every frame changes nothing - return; - } - let cvars = compose_file(&persist.file, &persist.session_owned, &snapshot); + let file = cvars.compose(); let body = toml::to_string(&LocalConfig { - cvars: cvars.clone(), + cvars: file.clone(), }) .expect("string map serializes"); match crate::local_state::write_atomic(&path, &format!("{HEADER}{body}")) { Ok(()) => { - persist.file = cvars; - persist.dirty = false; + cvars.file = file; + cvars.dirty = false; } Err(e) => { warn!("config: cannot write {}: {e}", path.display()); - persist.dirty = false; // don't retry every frame into the same error + cvars.dirty = false; // don't retry every frame into the same error } } } +/// Freeze the texture filter policy for the process, and say what it resolved to. +/// +/// **A separate system, chained after [`load_config`], deliberately.** `load_config` returns early +/// on an absent or malformed file, and the policy has to be published on every one of those paths: +/// the sampler lanes are an async `AssetLoader` and a set of ordinary systems, none of which can +/// read a resource the others own, so a run that never published would be reading +/// [`benilla_assets::tex_filter`]'s fallback while a player's `config.toml` said otherwise. +/// +/// The log line is not decoration — it is the same reasoning as `video::log_display_session` +/// (1627). Every filtering report this client will get comes from a machine nobody here can run, +/// and "which mode was that run actually in" must be readable off the log a player pastes rather +/// than reasoned about. +fn publish_filter_policy(filter: Res) { + benilla_assets::publish_tex_filter(*filter); + let mode = filter.mode(); + let name = match mode { + 3 => "bilinear + nearest-mip select, aniso off", + 4 => "trilinear, aniso off", + _ => "trilinear + aniso", + }; + info!( + "texture filter: mode {mode} ({name}) — trilinear={} anisotropic={}", + u8::from(filter.trilinear), + filter.aniso + ); +} + #[cfg(test)] mod tests { use super::*; - use crate::ui_script::DEFAULT_UI_SCALE; + use crate::chat_bubble::BubbleConfig; + use crate::minimap::MinimapZoom; + use crate::nameplates::NameConfig; + use crate::player::camera::{ + FollowConfig, FollowStyle, LookConfig, ZoomLimit, FOLLOW_SPEED_RANGE, + }; + use crate::portrait::PaneRate; + use crate::sound::SoundConfig; + use crate::target::ClickConfig; + use crate::ui_loot::LootConfig; + use crate::ui_script::{UiScaleCvar, DEFAULT_UI_SCALE}; + use crate::video::VideoConfig; + use crate::vplates::VPlateMode; + use crate::world_backdrop::{RenderScale, RENDER_SCALE_RANGE}; + use benilla_ui::widget::MINIMAP_ZOOM_LEVELS; + use benilla_world::clutter::ClutterConfig; + use benilla_world::view::{MsaaSetting, ViewDistance, FARCLIP_RANGE, MSAA_RANGE}; /// **The standard, enforced: a benilla option's default IS the reference's** (decision 1804) /// — every row's [`Reference`] column stands up. @@ -2626,278 +2413,215 @@ mod tests { assert_eq!(d["renderScale"], 1.0); } + /// **Every arm, through the registry and its observers.** The old central `apply_to_knobs` + /// match is gone (2303); each arm lives beside the knob it writes, and this drives the whole + /// set through a real `App` — a host write, the observers firing synchronously — so a knob + /// whose observer forgot an arm, or clamps differently from what its row promises, fails here. + /// + /// `apply` crosses the latch boundary at once, so the arms are what is under test; the latch + /// itself has its own test. #[test] - fn apply_parses_clamps_and_reports_unknowns() { - let mut sound = SoundConfig::default(); - let mut scale = UiScaleCvar(0.9); - let mut view = ViewDistance { - farclip: 350.0, - nearclip: benilla_world::view::NEARCLIP_DEFAULT, - }; - let mut look = LookConfig::default(); - let mut click = ClickConfig::default(); - let mut loot = LootConfig::default(); - let mut names = NameConfig::default(); - let mut plates = VPlateMode::default(); - let mut assist_attack = crate::target::AssistAttack::default(); - let mut combat_ranges = crate::ui_chat::combat::CombatLogRanges::default(); - let mut damage_text = crate::combat_text::DamageTextGates::default(); - let mut log_periodic = crate::ui_chat::combat::LogPeriodicSpells::default(); - // Literal fields, not Default: ClutterConfig::default() reads the env A/B vars. - let mut clutter = ClutterConfig { - density: 3.0, - scale: 1.0, - alpha_ref: 0.5, - fade_far: 70.0, - }; - let mut weather = benilla_world::weather::WeatherState::default(); - let mut display_gamma = crate::ui_gamma::DisplayGamma::default(); - let mut minimap = MinimapZoom::default(); - let mut bubbles = BubbleConfig::default(); - let mut zoom = ZoomLimit::default(); - let mut follow = FollowConfig::default(); - let mut video = VideoConfig::default(); - let mut pane_rate = PaneRate::default(); - let mut guild_notify = crate::ui_guild::GuildMemberNotify::default(); - let mut block_trades = crate::ui_trade::BlockTrades::default(); - // Literal, not Default: MsaaSetting::default() reads $WOW_MSAA. - let mut msaa = MsaaSetting { samples: 1 }; - // What an Apple GPU answers for the trio we render into (Rgba16Float / Depth32Float / - // the swapchain). 8 and 16 are NOT in it — which is the whole point below. - let msaa_formats = benilla_world::view::MsaaFormats { - formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)], - }; - // Literal for the same reason: RenderScale::default() reads $WOW_RENDER_SCALE. - let mut render_scale = RenderScale(1.0); - // Literal for the same reason again (1642): TexFilterSetting::default() reads - // $WOW_TRILINEAR / $WOW_ANISO. These are what ships (1645). - let mut tex_filter = benilla_assets::TexFilterSetting { - trilinear: true, - aniso: 1, - }; - // Literal for the same reason once more (1667): Realmlist::default() reads $WOW_HOST, and - // a shell that happens to export it must not decide what this test asserts against. - let mut realmlist = - crate::realmlist::Realmlist::unpinned(crate::realmlist::DEFAULT_REALMLIST); - let mut auto_self_cast = crate::ui_action::AutoSelfCast::default(); - let mut fps_journal = crate::perf::FpsJournalSetting::default(); - let mut text_filter = crate::text_filter::TextFilterSwitches::default(); - let mut game_tip = crate::game_tip::GameTipSetting::default(); - let mut camera_opts = crate::player::camera_dynamics::CameraOptions::default(); - let mut knobs = Knobs { - camera_opts: &mut camera_opts, - sound: &mut sound, - auto_self_cast: &mut auto_self_cast, - text_filter: &mut text_filter, - game_tip: &mut game_tip, - scale: &mut scale, - view: &mut view, - look: &mut look, - click: &mut click, - loot: &mut loot, - names: &mut names, - plates: &mut plates, - clutter: &mut clutter, - weather: &mut weather, - display_gamma: &mut display_gamma, - minimap: &mut minimap, - bubbles: &mut bubbles, - zoom: &mut zoom, - follow: &mut follow, - video: &mut video, - render_scale: &mut render_scale, - pane_rate: &mut pane_rate, - guild_notify: &mut guild_notify, - block_trades: &mut block_trades, - msaa: &mut msaa, - tex_filter: &mut tex_filter, - msaa_formats: &msaa_formats, - realmlist: &mut realmlist, - fps_journal: &mut fps_journal, - assist_attack: &mut assist_attack, - combat_ranges: &mut combat_ranges, - damage_text: &mut damage_text, - log_periodic: &mut log_periodic, - }; - assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs)); - assert_eq!(knobs.sound.music, 0.7); + fn the_observers_apply_every_arm() { + let mut app = cvar_app(); + apply(&mut app, "MusicVolume", "0.7"); + assert_eq!(res::(&app).music, 0.7); // The second string-valued row (1667): it must reach the knob rather than being rejected // by the numeric parse every other row goes through, and a value that is not an address // must be consumed (known key) while leaving the knob's truth alone. - assert!(apply_to_knobs( - "realmList", - "logon.example.org:3724", - &mut knobs - )); - assert_eq!(knobs.realmlist.address(), "logon.example.org:3724"); - assert!(apply_to_knobs( + apply(&mut app, "realmList", "logon.example.org:3724"); + assert_eq!( + res::(&app).address(), + "logon.example.org:3724" + ); + apply( + &mut app, "realmlist", r#"SET realmlist "elsewhere.example.org""#, - &mut knobs - )); - assert_eq!(knobs.realmlist.address(), "elsewhere.example.org"); - assert!(apply_to_knobs("realmList", "not an address", &mut knobs)); + ); assert_eq!( - knobs.realmlist.address(), + res::(&app).address(), + "elsewhere.example.org" + ); + apply(&mut app, "realmList", "not an address"); + assert_eq!( + res::(&app).address(), "elsewhere.example.org", "a known key with a bad value is consumed, and the resource keeps its truth", ); // Clamps are the knob's own: volume to [0,1], farclip to FARCLIP_RANGE. - assert!(apply_to_knobs("mastervolume", "7", &mut knobs)); - assert_eq!(knobs.sound.master, 1.0); - assert!(apply_to_knobs("farclip", "50", &mut knobs)); - assert_eq!(knobs.view.farclip, *FARCLIP_RANGE.start()); + apply(&mut app, "mastervolume", "7"); + assert_eq!(res::(&app).master, 1.0); + apply(&mut app, "farclip", "50"); + assert_eq!(res::(&app).farclip, *FARCLIP_RANGE.start()); // `nearclip` clamps to the reference's own callback bounds `[0.01, 0.33]` (`0x688d90`), // both ends. pfUI's extended stops write 0.06..0.30, so its whole range passes untouched. - assert!(apply_to_knobs("nearclip", "0.001", &mut knobs)); + apply(&mut app, "nearclip", "0.001"); assert_eq!( - knobs.view.nearclip, 0.01, + res::(&app).nearclip, + 0.01, "[0x8029d0], the callback's low bound" ); - assert!(apply_to_knobs("nearclip", "9", &mut knobs)); - assert_eq!(knobs.view.nearclip, 0.33, "[0x808300], its high bound"); - assert!(apply_to_knobs("nearclip", "0.3", &mut knobs)); - assert_eq!(knobs.view.nearclip, 0.3); + apply(&mut app, "nearclip", "9"); + assert_eq!( + res::(&app).nearclip, + 0.33, + "[0x808300], its high bound" + ); + apply(&mut app, "nearclip", "0.3"); + assert_eq!(res::(&app).nearclip, 0.3); // Multisampling clamps to the reference's own [1, 16] and takes an int the way its `atoi` // does — the value reaching the camera is a sample COUNT, where 1 is none (1629). - assert!(apply_to_knobs("gxMultisample", "4", &mut knobs)); - assert_eq!(knobs.msaa.samples, 4); + apply(&mut app, "gxMultisample", "4"); + assert_eq!(res::(&app).samples, 4); // The filter policy's two rows: `anisotropic` takes the reference's own [1, 16] clamp, // `trilinear` is a flag. Both write the pending value; the process policy is already // published by the time either can be typed (1642). - assert!(apply_to_knobs("anisotropic", "99", &mut knobs)); - assert_eq!(knobs.tex_filter.aniso, *benilla_assets::ANISO_RANGE.end()); - assert!(apply_to_knobs("anisotropic", "0", &mut knobs)); - assert_eq!(knobs.tex_filter.aniso, *benilla_assets::ANISO_RANGE.start()); + apply(&mut app, "anisotropic", "99"); + assert_eq!( + res::(&app).aniso, + *benilla_assets::ANISO_RANGE.end() + ); + apply(&mut app, "anisotropic", "0"); + assert_eq!( + res::(&app).aniso, + *benilla_assets::ANISO_RANGE.start() + ); // Both directions: the knob starts at what ships (on), so only the flip to 0 proves the // arm does anything. - assert!(apply_to_knobs("trilinear", "0", &mut knobs)); - assert!(!knobs.tex_filter.trilinear); - assert!(apply_to_knobs("trilinear", "1", &mut knobs)); - assert!(knobs.tex_filter.trilinear); + apply(&mut app, "trilinear", "0"); + assert!(!res::(&app).trilinear); + apply(&mut app, "trilinear", "1"); + assert!(res::(&app).trilinear); // **The DEVICE's ceiling, not the reference's** (decision 1643). 99 clamps to the // reference's 16 and then to the 4 this GPU offers — before 1643 it stopped at 16 and the // camera was handed a sample count wgpu refuses, killing the render thread on frame one. - assert!(apply_to_knobs("gxmultisample", "99", &mut knobs)); - assert_eq!(knobs.msaa.samples, 4); + apply(&mut app, "gxmultisample", "99"); + assert_eq!(res::(&app).samples, 4); // The realistic route in: a config written where 8x exists, opened where it does not. - assert!(apply_to_knobs("gxMultisample", "8", &mut knobs)); + apply(&mut app, "gxMultisample", "8"); assert_eq!( - knobs.msaa.samples, 4, + res::(&app).samples, + 4, "a device that stops at 4x must never be handed an 8" ); // A count the device DOES offer is untouched. - assert!(apply_to_knobs("gxmultisample", "2", &mut knobs)); - assert_eq!(knobs.msaa.samples, 2); - assert!(apply_to_knobs("gxmultisample", "0", &mut knobs)); - assert_eq!(knobs.msaa.samples, *MSAA_RANGE.start()); + apply(&mut app, "gxmultisample", "2"); + assert_eq!(res::(&app).samples, 2); + apply(&mut app, "gxmultisample", "0"); + assert_eq!(res::(&app).samples, *MSAA_RANGE.start()); // Render scale takes a fraction and clamps to its own range at both ends (1639). - assert!(apply_to_knobs("renderScale", "0.75", &mut knobs)); - assert_eq!(knobs.render_scale.0, 0.75); - assert!(apply_to_knobs("renderscale", "9", &mut knobs)); - assert_eq!(knobs.render_scale.0, *RENDER_SCALE_RANGE.end()); - assert!(apply_to_knobs("renderscale", "0", &mut knobs)); - assert_eq!(knobs.render_scale.0, *RENDER_SCALE_RANGE.start()); + apply(&mut app, "renderScale", "0.75"); + assert_eq!(res::(&app).0, 0.75); + apply(&mut app, "renderscale", "9"); + assert_eq!(res::(&app).0, *RENDER_SCALE_RANGE.end()); + apply(&mut app, "renderscale", "0"); + assert_eq!(res::(&app).0, *RENDER_SCALE_RANGE.start()); // The FPS journal switch (2008): a flag, case-insensitive, off as shipped. - assert!(!knobs.fps_journal.0); - assert!(apply_to_knobs("fpsJournal", "1", &mut knobs)); - assert!(knobs.fps_journal.0); - assert!(apply_to_knobs("fpsjournal", "0", &mut knobs)); - assert!(!knobs.fps_journal.0); + assert!(!res::(&app).0); + apply(&mut app, "fpsJournal", "1"); + assert!(res::(&app).0); + apply(&mut app, "fpsjournal", "0"); + assert!(!res::(&app).0); // Enable flags: any nonzero is on, zero is off (the client's int-parse + != 0). - assert!(apply_to_knobs("EnableMusic", "0", &mut knobs)); - assert!(!knobs.sound.music_enabled); - assert!(apply_to_knobs("mastersoundeffects", "1", &mut knobs)); - assert!(knobs.sound.enabled); + apply(&mut app, "EnableMusic", "0"); + assert!(!res::(&app).music_enabled); + apply(&mut app, "mastersoundeffects", "1"); + assert!(res::(&app).enabled); // The Controls trio lands on its knobs (case-insensitive like everything else). - assert!(apply_to_knobs("deselectonclick", "0", &mut knobs)); - assert!(!knobs.click.deselect_on_click); - assert!(apply_to_knobs("MouseInvertPitch", "1", &mut knobs)); - assert!(knobs.look.invert_pitch); + apply(&mut app, "deselectonclick", "0"); + assert!(!res::(&app).deselect_on_click); + apply(&mut app, "MouseInvertPitch", "1"); + assert!(res::(&app).invert_pitch); // The sensitivity multiplier clamps to the 1.12 slider's range at the knob. - assert!(apply_to_knobs("mousespeed", "1.4", &mut knobs)); - assert_eq!(knobs.look.sensitivity, 1.4); - assert!(apply_to_knobs("mousespeed", "9", &mut knobs)); - assert_eq!(knobs.look.sensitivity, 1.5); + apply(&mut app, "mousespeed", "1.4"); + assert_eq!(res::(&app).sensitivity, 1.4); + apply(&mut app, "mousespeed", "9"); + assert_eq!(res::(&app).sensitivity, 1.5); // The following style lands as the ENGINE's enum (0 Never / 1 Smart / 2 Always), and the // "3" the reference's own dropdown writes for Never still means Never. - assert!(apply_to_knobs("cameraSmoothStyle", "0", &mut knobs)); - assert_eq!(knobs.follow.style, FollowStyle::Never); - assert!(apply_to_knobs("camerasmoothstyle", "2", &mut knobs)); - assert_eq!(knobs.follow.style, FollowStyle::Always); - assert!(apply_to_knobs("cameraSmoothStyle", "3", &mut knobs)); - assert_eq!(knobs.follow.style, FollowStyle::Never); - assert!(apply_to_knobs("cameraSmoothStyle", "1", &mut knobs)); - assert_eq!(knobs.follow.style, FollowStyle::Smart); + apply(&mut app, "cameraSmoothStyle", "0"); + assert_eq!(res::(&app).style, FollowStyle::Never); + apply(&mut app, "camerasmoothstyle", "2"); + assert_eq!(res::(&app).style, FollowStyle::Always); + apply(&mut app, "cameraSmoothStyle", "3"); + assert_eq!(res::(&app).style, FollowStyle::Never); + apply(&mut app, "cameraSmoothStyle", "1"); + assert_eq!(res::(&app).style, FollowStyle::Smart); // Its two siblings land on the same knob — the tracking selector, and the rate, which // clamps to 1.12's AUTO_FOLLOW_SPEED slider range. - assert!(apply_to_knobs("cameraSmoothTrackingStyle", "2", &mut knobs)); - assert_eq!(knobs.follow.tracking_style, FollowStyle::Always); - assert_eq!(knobs.follow.style, FollowStyle::Smart, "and only that one"); - assert!(apply_to_knobs("cameraYawSmoothSpeed", "270", &mut knobs)); - assert_eq!(knobs.follow.yaw_speed, 270.0); - assert!(apply_to_knobs("cameraYawSmoothSpeed", "9000", &mut knobs)); - assert_eq!(knobs.follow.yaw_speed, *FOLLOW_SPEED_RANGE.end()); + apply(&mut app, "cameraSmoothTrackingStyle", "2"); + assert_eq!( + res::(&app).tracking_style, + FollowStyle::Always + ); + assert_eq!( + res::(&app).style, + FollowStyle::Smart, + "and only that one" + ); + apply(&mut app, "cameraYawSmoothSpeed", "270"); + assert_eq!(res::(&app).yaw_speed, 270.0); + apply(&mut app, "cameraYawSmoothSpeed", "9000"); + assert_eq!( + res::(&app).yaw_speed, + *FOLLOW_SPEED_RANGE.end() + ); // The max-orbit factor lands as YARDS on the knob (base 15 x factor), clamped to 1..2. - assert!(apply_to_knobs("cameraDistanceMaxFactor", "1", &mut knobs)); - assert_eq!(knobs.zoom.max, 15.0); - assert!(apply_to_knobs("cameradistancemaxfactor", "5", &mut knobs)); - assert_eq!(knobs.zoom.max, 30.0); - assert!(apply_to_knobs("autoLootDefault", "1", &mut knobs)); - assert!(knobs.loot.auto_loot); - assert!(apply_to_knobs("showLootSpam", "0", &mut knobs)); - assert!(!knobs.loot.show_loot_spam); + apply(&mut app, "cameraDistanceMaxFactor", "1"); + assert_eq!(res::(&app).max, 15.0); + apply(&mut app, "cameradistancemaxfactor", "5"); + assert_eq!(res::(&app).max, 30.0); + apply(&mut app, "autoLootDefault", "1"); + assert!(res::(&app).auto_loot); + apply(&mut app, "showLootSpam", "0"); + assert!(!res::(&app).show_loot_spam); // Guild Member Alert (1589) — the row that ships OFF, so its ON is the interesting write. - assert!(apply_to_knobs("guildMemberNotify", "1", &mut knobs)); - assert!(knobs.guild_notify.0); + apply(&mut app, "guildMemberNotify", "1"); + assert!(res::(&app).0); // Block Trades (1764) — the other row that ships OFF; its ON is what refuses a trade. - assert!(apply_to_knobs("BlockTrades", "1", &mut knobs)); - assert!(knobs.block_trades.0); + apply(&mut app, "BlockTrades", "1"); + assert!(res::(&app).0); // The name trio lands on its gates (0992). - assert!(apply_to_knobs("UnitNameNPC", "0", &mut knobs)); - assert!(!knobs.names.npc); - assert!(apply_to_knobs("unitnameown", "1", &mut knobs)); - assert!(knobs.names.own); + apply(&mut app, "UnitNameNPC", "0"); + assert!(!res::(&app).npc); + apply(&mut app, "unitnameown", "1"); + assert!(res::(&app).own); // …and the plate pair on the two bits of the bitmask, either casing. - assert!(apply_to_knobs( - crate::vplates::CVAR_ENEMIES, - "0", - &mut knobs - )); - assert!(!knobs.plates.enemies); - assert!(apply_to_knobs("nameplateshowfriends", "1", &mut knobs)); - assert!(knobs.plates.friends); + apply(&mut app, crate::vplates::CVAR_ENEMIES, "0"); + assert!(!res::(&app).enemies); + apply(&mut app, "nameplateshowfriends", "1"); + assert!(res::(&app).friends); // The bubble pair lands on the spawn gate's own knob (1139). - assert!(apply_to_knobs("ChatBubbles", "0", &mut knobs)); - assert!(!knobs.bubbles.all); - assert!(apply_to_knobs("chatbubblesparty", "0", &mut knobs)); - assert!(!knobs.bubbles.party); + apply(&mut app, "ChatBubbles", "0"); + assert!(!res::(&app).all); + apply(&mut app, "chatbubblesparty", "0"); + assert!(!res::(&app).party); // WorldDetail: panel 0/1/2 → density ×1/×2/×3, clamped to the 1.12 slider's range. - assert!(apply_to_knobs("WorldDetail", "0", &mut knobs)); - assert_eq!(knobs.clutter.density, 1.0); - assert!(apply_to_knobs("worlddetail", "7", &mut knobs)); - assert_eq!(knobs.clutter.density, 3.0); + apply(&mut app, "WorldDetail", "0"); + assert_eq!(res::(&app).density, 1.0); + apply(&mut app, "worlddetail", "7"); + assert_eq!(res::(&app).density, 3.0); // frillDensity: the SAME field in the reference's cells-per-chunk (2151), and the two // arms' clamps are deliberately different — the stop's `[0, 2]` above, the cells' // `[1, 256]` here (callback `0x688de0`). The stops round-trip through both spellings, // which is the property that makes them one knob rather than two that agree by habit. - assert!(apply_to_knobs("frillDensity", "48", &mut knobs)); - assert_eq!(knobs.clutter.density, 3.0); - assert!(apply_to_knobs("frilldensity", "16", &mut knobs)); - assert_eq!(knobs.clutter.density, 1.0); + apply(&mut app, "frillDensity", "48"); + assert_eq!(res::(&app).density, 3.0); + apply(&mut app, "frilldensity", "16"); + assert_eq!(res::(&app).density, 1.0); // Past the top stop is HONOURED, not clamped to it — pfUI's `hdgraphic` drives exactly // this, `ConsoleExec("frillDensity " .. (arg+1)*16)` for arg up to 15. - assert!(apply_to_knobs("frillDensity", "256", &mut knobs)); - assert_eq!(knobs.clutter.density, 16.0); + apply(&mut app, "frillDensity", "256"); + assert_eq!(res::(&app).density, 16.0); // …and the reference's own bounds hold at both ends. `0` is NOT clutter-off: the callback // pins it to 1, and turning grass off stays the `$WOW_CLUTTER_DENSITY` instrument's. - assert!(apply_to_knobs("frillDensity", "9000", &mut knobs)); - assert_eq!(knobs.clutter.density, 16.0); - assert!(apply_to_knobs("frillDensity", "0", &mut knobs)); - assert_eq!(knobs.clutter.density, 1.0 / 16.0); + apply(&mut app, "frillDensity", "9000"); + assert_eq!(res::(&app).density, 16.0); + apply(&mut app, "frillDensity", "0"); + assert_eq!(res::(&app).density, 1.0 / 16.0); // The row `GetCVar` answers is the same field seen the other way round. - assert!(apply_to_knobs("WorldDetail", "1", &mut knobs)); - assert_eq!(knobs.clutter.density, 2.0); + apply(&mut app, "WorldDetail", "1"); + assert_eq!(res::(&app).density, 2.0); // Weather Intensity (2181): the panel's four stops land whole, an off-grid value // truncates toward zero the way every int-valued row here does, and both ends clamp. for (wrote, want) in [ @@ -2909,9 +2633,10 @@ mod tests { ("9", 3), ("-4", 0), ] { - assert!(apply_to_knobs("weatherDensity", wrote, &mut knobs)); + apply(&mut app, "weatherDensity", wrote); assert_eq!( - knobs.weather.weather_density, want, + res::(&app).weather_density, + want, "weatherDensity {wrote}" ); } @@ -2920,33 +2645,40 @@ mod tests { // Both ends of the stock slider land whole, and the consumer's clamp holds the values the // reference accepts without one (`SetGamma(5)` writes -4 there). for (wrote, want) in [("1.000000", 1.0), ("0.500000", 0.5), ("1.500000", 1.5)] { - assert!(apply_to_knobs("gamma", wrote, &mut knobs)); - assert_eq!(knobs.display_gamma.0, want, "gamma {wrote}"); + apply(&mut app, "gamma", wrote); + assert_eq!( + res::(&app).0, + want, + "gamma {wrote}" + ); } - assert!(apply_to_knobs("gamma", "-4.000000", &mut knobs)); + apply(&mut app, "gamma", "-4.000000"); assert_eq!( - knobs.display_gamma.0, + res::(&app).0, *crate::ui_gamma::GAMMA_RANGE.start(), "a negative exponent clamps at the consumer, where it cannot blank the screen" ); - assert!(apply_to_knobs("gamma", "99", &mut knobs)); - assert_eq!(knobs.display_gamma.0, *crate::ui_gamma::GAMMA_RANGE.end()); - assert_eq!(knobs.clutter.frill_density(), 32.0); - // And the pair is NAMED as a pair, in the registered spelling and the lowercased one, so - // `$WOW_CLUTTER_DENSITY` cannot take one spelling of this knob for the session and leave - // the other free to persist the lever (2151). - for key in CLUTTER_DENSITY_CVARS { - assert!( - REGISTERED.iter().any(|r| r.name.eq_ignore_ascii_case(key)), - "{key}: named as a clutter-density spelling but not registered" + apply(&mut app, "gamma", "99"); + assert_eq!( + res::(&app).0, + *crate::ui_gamma::GAMMA_RANGE.end() + ); + assert_eq!(res::(&app).frill_density(), 32.0); + // Both spellings reach the same field, from a state neither of them holds — and + // `$WOW_CLUTTER_DENSITY` takes both for the session (`session_values`), so the lever + // cannot ride into `config.toml` through the other name (2151). Two different values, + // because a write of the sibling's mirrored value is the no-op it should be. + for (key, value) in [ + (benilla_ui::script::CVAR_WORLD_DETAIL, "2"), + (benilla_ui::script::CVAR_FRILL_DENSITY, "16"), + ] { + app.world_mut().resource_mut::().density = 0.5; + assert_eq!(apply(&mut app, key, value), SetOutcome::Changed); + assert_ne!( + res::(&app).density, + 0.5, + "{key}: reached no knob" ); - assert_eq!(key.to_ascii_lowercase(), key, "the set is lowercased keys"); - } - // Both of them reach the same field, from a state neither of them holds. - for key in CLUTTER_DENSITY_CVARS { - knobs.clutter.density = 0.5; - assert!(apply_to_knobs(key, "48", &mut knobs)); - assert_ne!(knobs.clutter.density, 0.5, "{key}: reached no knob"); } // **The engine verbs' half of the same weld** (2163). `SetWorldDetail`/`GetWorldDetail` live // in `benilla-ui`, which cannot see this table, so the two CVar names and the stop table it @@ -2984,56 +2716,59 @@ mod tests { ); // Either spelling of the stop lands on the same ground cover — which is what lets the // setter write `frillDensity` and the getter read `WorldDetail` without disagreeing. - assert!(apply_to_knobs("WorldDetail", &n.to_string(), &mut knobs)); - let by_stop = knobs.clutter.density; - knobs.clutter.density = 0.5; - assert!(apply_to_knobs( - "frillDensity", - &frill.to_string(), - &mut knobs - )); + apply(&mut app, "WorldDetail", &n.to_string()); + let by_stop = res::(&app).density; + // Off the stop first — the mirror already wrote this spelling — then the stop's + // own cells through it. + apply(&mut app, "frillDensity", "1"); + app.world_mut().resource_mut::().density = 0.5; + apply(&mut app, "frillDensity", &frill.to_string()); assert_eq!( - knobs.clutter.density, by_stop, + res::(&app).density, + by_stop, "stop {n}: the two spellings disagree" ); - assert_eq!(knobs.clutter.frill_density(), *frill as f32); + assert_eq!(res::(&app).frill_density(), *frill as f32); } // Back to the shipped stop, so the rows after this one read the default ground cover. - assert!(apply_to_knobs("WorldDetail", "1", &mut knobs)); - assert_eq!(knobs.clutter.density, 2.0); + apply(&mut app, "WorldDetail", "1"); + assert_eq!(res::(&app).density, 2.0); // The zoom pair (1131): each index lands on its own field, clamped like `set_zoom`. - assert!(apply_to_knobs("minimapZoom", "5", &mut knobs)); - assert_eq!(knobs.minimap.outdoor, 5); - assert_eq!(knobs.minimap.inside, 3, "the two indices are independent"); - assert!(apply_to_knobs("minimapinsidezoom", "9", &mut knobs)); - assert_eq!(knobs.minimap.inside, MINIMAP_ZOOM_LEVELS - 1); - assert!(apply_to_knobs("minimapZoom", "-2", &mut knobs)); - assert_eq!(knobs.minimap.outdoor, 0); - // A bad value is consumed (known key) and the resource keeps its truth. - assert!(apply_to_knobs("uiScale", "banana", &mut knobs)); - assert_eq!(knobs.scale.0, 0.9); - assert!(!apply_to_knobs("bogus", "1", &mut knobs)); + apply(&mut app, "minimapZoom", "5"); + assert_eq!(res::(&app).outdoor, 5); + assert_eq!( + res::(&app).inside, + 3, + "the two indices are independent" + ); + apply(&mut app, "minimapinsidezoom", "9"); + assert_eq!(res::(&app).inside, MINIMAP_ZOOM_LEVELS - 1); + apply(&mut app, "minimapZoom", "-2"); + assert_eq!(res::(&app).outdoor, 0); + // A bad value on a numeric row is REFUSED by the registry (2303) — the resource keeps + // its truth and the row keeps its value; an unknown name is reported as such. + assert_eq!(apply(&mut app, "uiScale", "banana"), SetOutcome::Refused); + assert_eq!(res::(&app).0, 0.9); + assert_eq!(apply(&mut app, "bogus", "1"), SetOutcome::Unknown); } #[test] fn compose_writes_the_diff_and_preserves_what_it_does_not_own() { - let previous: BTreeMap = [ - ("FutureKnob".to_string(), "3".to_string()), // a newer build's key: preserved - ("uiScale".to_string(), "0.8".to_string()), // env-overridden this session - ("farclip".to_string(), "400".to_string()), // will return to default - ] - .into(); - let env: HashSet = ["uiscale".to_string()].into(); - let snapshot = vec![ - // (name, value, default) - ("MusicVolume".into(), "0.7".into(), "0.4".into()), // moved: written - ("MasterVolume".into(), "1".into(), "1".into()), // default: absent - ("uiScale".into(), "1.2".into(), "0.9".into()), // env value: file keeps 0.8 - ("farclip".into(), "350".into(), "350".into()), // back to default: removed - ]; - let out = compose_file(&previous, &env, &snapshot); + let mut cvars = Cvars::default(); + cvars.own_for_session("uiScale", Some("1.2")); // env-overridden this session + cvars.load_file( + [ + ("FutureKnob".to_string(), "3".to_string()), // a newer build's key: preserved + ("uiScale".to_string(), "0.8".to_string()), // the file's own, kept as found + ("farclip".to_string(), "400".to_string()), // will return to default + ] + .into(), + ); + cvars.set("MusicVolume", "0.7"); // moved: written + cvars.set("farclip", "350"); // back to default: removed + let out = cvars.compose(); assert_eq!(out.get("MusicVolume").map(String::as_str), Some("0.7")); - assert!(!out.contains_key("MasterVolume")); + assert!(!out.contains_key("MasterVolume"), "at default: absent"); assert_eq!(out.get("uiScale").map(String::as_str), Some("0.8")); assert!(!out.contains_key("farclip")); assert_eq!(out.get("FutureKnob").map(String::as_str), Some("3")); @@ -3094,8 +2829,8 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } - /// A client whose CVar host is real: every knob resource the [`KnobParams`] census wants, - /// [`CvarPlugin`] itself, and a VM for the table to live in. The three end-to-end tests below + /// A client whose CVar host is real: every knob resource an observer writes, every + /// observer, [`CvarPlugin`] itself, and a VM for the mirror to live in. The end-to-end tests below /// each stand a whole client up, and the census is one row per knob — copied per test, adding /// a knob meant editing every copy. fn cvar_app() -> App { @@ -3151,19 +2886,117 @@ mod tests { .init_resource::() .init_resource::() .add_plugins(CvarPlugin); + for observer in ALL_OBSERVERS { + observer(&mut app); + } app.insert_non_send_resource(UiScript::new().unwrap()); app } + /// Every CVar observer in the crate, as the plugins register them — the census this test + /// module stands a client up with. A new observer is one line here and one + /// `add_observer` in its plugin. + const ALL_OBSERVERS: &[fn(&mut App)] = &[ + |app| { + app.add_observer(crate::sound::on_cvar); + }, + |app| { + app.add_observer(crate::ui_script::on_cvar); + }, + |app| { + app.add_observer(crate::video::on_cvar); + }, + |app| { + app.add_observer(crate::player::camera::on_cvar); + }, + |app| { + app.add_observer(crate::player::camera_dynamics::on_cvar); + }, + |app| { + app.add_observer(crate::target::on_cvar); + }, + |app| { + app.add_observer(crate::ui_action::on_cvar); + }, + |app| { + app.add_observer(crate::combat_text::on_cvar); + }, + |app| { + app.add_observer(crate::ui_chat::combat::on_cvar); + }, + |app| { + app.add_observer(crate::ui_loot::on_cvar); + }, + |app| { + app.add_observer(crate::nameplates::on_cvar); + }, + |app| { + app.add_observer(crate::vplates::on_cvar); + }, + |app| { + app.add_observer(crate::game_tip::on_cvar); + }, + |app| { + app.add_observer(crate::text_filter::on_cvar); + }, + |app| { + app.add_observer(crate::chat_bubble::on_cvar); + }, + |app| { + app.add_observer(crate::ui_guild::on_cvar); + }, + |app| { + app.add_observer(crate::ui_trade::on_cvar); + }, + |app| { + app.add_observer(crate::minimap::on_cvar); + }, + |app| { + app.add_observer(crate::portrait::on_cvar); + }, + |app| { + app.add_observer(crate::perf::on_cvar); + }, + |app| { + app.add_observer(crate::realmlist::on_cvar); + }, + |app| { + app.add_observer(crate::ui_gamma::on_cvar); + }, + |app| { + app.add_observer(crate::world_backdrop::on_cvar); + }, + ]; + + /// A host write, its latch committed at once, and its observers run before this returns. + fn apply(app: &mut App, name: &str, value: &str) -> SetOutcome { + let world = app.world_mut(); + let (outcome, events) = { + let mut cvars = world.resource_mut::(); + let outcome = cvars.set(name, value); + cvars.commit_latched(); + (outcome, cvars.take_events()) + }; + for event in events { + world.trigger(event); + } + outcome + } + + fn res(app: &App) -> &T { + app.world().resource::() + } + /// **The reported bug, end to end** (decision 1622): "char screen doesn't remember the last /// logged in char, the ref does". Two launches over one `benilla-config/`, with the real /// [`CvarPlugin`] and the real [`crate::char_select`] systems in between — entering the world /// as somebody has to survive the quit and bring the screen back to them. /// - /// The seam this covers and the per-module tests cannot: `set_cvar_engine`'s queued change is - /// only *persisted* if [`apply_to_knobs`] answers `true` for the name. A knobless CVar that - /// falls through to `_ => return false` reaches the VM's table, reads back correctly all - /// session, and is silently dropped at the save — which is this bug again, one layer down. + /// The seam this covers and the per-module tests cannot: the screen's host write has to + /// reach the file through the registry's own dirty/compose path. Before 2303 a knobless CVar + /// fell through the central arm match to `_ => return false`, reached the VM's table, read + /// back correctly all session, and was silently dropped at the save — this bug again, one + /// layer down. #[test] fn entering_the_world_survives_the_quit_and_comes_back_selected() { use crate::char_select::{ClientState, Roster}; @@ -3322,6 +3155,7 @@ mod tests { let parsed: LocalConfig = toml::from_str(hand).unwrap(); assert_eq!(parsed.cvars.get("Farclip").map(String::as_str), Some("500")); } + /// **The table's string-valued CVars, named — and each one's default asserted on its own /// terms.** /// @@ -3337,14 +3171,14 @@ mod tests { /// cleanly, and inventing a realm name would be worse than admitting we have none yet. /// /// **`gxResolution` defaults to the pre-1627 window** (decision 1627), and **`realmList` to - /// `localhost`** (1667). These are the rows [`apply_to_knobs`] matches ahead of its numeric - /// parse, so each default is asserted through the same parser the live value goes through — a - /// spelling this table accepts but [`crate::video::parse_resolution`] or - /// [`crate::realmlist::normalize`] rejects would otherwise ship as a silent fall back. + /// `localhost`** (1667). These are the rows the registry takes any string for (a row's + /// default decides whether it is numeric — [`Cvars::set`]), so each default is asserted + /// through the parser its observer uses on the live value — a spelling this table accepts + /// but [`crate::video::parse_resolution`] or [`crate::realmlist::normalize`] rejects would + /// otherwise ship as a silent fall back. /// - /// The list itself is the load-bearing half: a new string-valued row that forgets its arm in - /// `apply_to_knobs` is a CVar the player can set and the client will never honour, and this is - /// what makes adding one impossible to do quietly. + /// The list itself is the load-bearing half: a new string-valued row has to come here and + /// name its parser, which is what makes adding one impossible to do quietly. #[test] fn the_string_valued_cvars_are_the_realm_and_the_windowed_size() { let mut strings: Vec<&str> = REGISTERED @@ -3383,30 +3217,331 @@ mod tests { ); } - /// **The claim the test above only asserted in prose, now enforced.** Its doc says a string - /// row that forgets its arm in [`apply_to_knobs`] "is a CVar the player can set and the client - /// will never honour, and this is what makes adding one impossible to do quietly" — and then - /// `realmName` was added and did exactly that. It reached the numeric parse, which can only - /// reject it, so every launch after the first connect logged - /// `cvar realmName: unparseable value 'VMaNGOS' ignored`. - /// - /// It was the mild half of the failure — the persisted value still reaches `GetCVar` through - /// `set_cvar_saved_base`, so nothing was actually lost, and the warn was libel rather than - /// news. A string row that DID own a knob would have been silently dropped. Both directions - /// are pinned: a new non-numeric row that skips [`is_string_valued`] fails here, and a key - /// named there that stops being a registered string row fails here too. + /// A registry with the file's entries and the session's env values, no disk: the pure + /// half of the load, for the tests below. + fn registry(file: &[(&str, &str)]) -> Cvars { + let mut cvars = Cvars::default(); + cvars.load_file( + file.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ); + cvars + } + + /// **The latch** (decision 2303): a write to a row the reference registers with flag bit1 + /// is staged, the applied value stands, nothing fires and nothing dirties — the reference's + /// `Set 0x63df50` storing to `latchedValue` with `InternalSet` skipped — until the boundary + /// commits it (`CVar::Update 0x63e060`), at which point it fires, persists and reaches the + /// mirror. Staging the applied value back clears the stage; a stage never committed is not + /// what the file gets. #[test] - fn every_string_valued_row_is_claimed_before_the_numeric_parse() { - for r in REGISTERED { - let key = r.name.to_ascii_lowercase(); - assert_eq!( - r.default.parse::().is_err(), - is_string_valued(&key), - "{}: a row's default parsing as a number and `is_string_valued` must agree — \ - a string row that misses the guard falls to the numeric parse, which only \ - rejects it", - r.name, + fn a_latched_row_stages_the_write_until_the_boundary_commits_it() { + let mut cvars = Cvars::default(); + assert!( + cvars.row("gxVSync").unwrap().latched, + "the reference's flags=3 row" + ); + assert_eq!(cvars.set("gxVSync", "0"), SetOutcome::Staged); + assert_eq!(cvars.get("gxVSync"), Some("1"), "applied value stands"); + assert_eq!(cvars.row("gxVSync").unwrap().pending.as_deref(), Some("0")); + assert!(!cvars.has_events(), "nothing fires before the boundary"); + assert!(!cvars.dirty, "nothing to save before the boundary"); + assert_eq!( + cvars.set("gxVSync", "0"), + SetOutcome::Unchanged, + "same stage" + ); + assert!( + !cvars.compose().contains_key("gxVSync"), + "a stage that is never committed never reaches the file" + ); + // The boundary. + assert_eq!(cvars.commit_latched(), 1); + assert_eq!(cvars.get("gxVSync"), Some("0")); + assert_eq!(cvars.row("gxVSync").unwrap().pending, None); + assert_eq!( + cvars.take_events(), + vec![CvarChanged { + name: "gxVSync".into(), + old: "1".into(), + new: "0".into() + }] + ); + assert!(cvars.dirty); + assert_eq!( + cvars.compose().get("gxVSync").map(String::as_str), + Some("0") + ); + assert_eq!( + cvars.take_outbox(), + vec![("gxVSync".to_string(), "0".to_string())] + ); + // Staging the applied value back over a stage clears the stage, and the next boundary + // has nothing to do. + cvars.set("gxVSync", "1"); + assert_eq!(cvars.set("gxVSync", "0"), SetOutcome::Unchanged); + assert_eq!(cvars.row("gxVSync").unwrap().pending, None); + assert_eq!(cvars.commit_latched(), 0); + // An unlatched row applies at once, fires, and dirties. + assert_eq!(cvars.set("MusicVolume", "0.7"), SetOutcome::Changed); + assert_eq!(cvars.get("MusicVolume"), Some("0.7")); + assert_eq!(cvars.take_events().len(), 1); + } + + /// **A numeric row refuses what does not parse** — the applied value stands, the mirror + /// that already stored the garbage is corrected through the outbox, and nothing fires. The + /// old central parse consumed the value and let it into the file; the registry does not. + /// A string row takes any string, and its observer is the one that judges it. + #[test] + fn a_numeric_row_refuses_what_does_not_parse_and_corrects_the_mirror() { + let mut cvars = Cvars::default(); + assert_eq!(cvars.set_from_vm("uiScale", "banana"), SetOutcome::Refused); + assert_eq!(cvars.get("uiScale"), Some("0.9")); + assert!(!cvars.has_events()); + assert!(!cvars.dirty); + assert_eq!( + cvars.take_outbox(), + vec![("uiScale".to_string(), "0.9".to_string())], + "the VM stored 'banana' synchronously; the host writes the truth back" + ); + assert_eq!(cvars.set("uiScale", "banana"), SetOutcome::Refused); + assert!( + cvars.take_outbox().is_empty(), + "a host write has no mirror to correct" + ); + assert_eq!( + cvars.set("realmList", "not an address"), + SetOutcome::Changed + ); + assert_eq!(cvars.set("nosuchrow", "1"), SetOutcome::Unknown); + } + + /// **An addon-declared row persists like the client's own** (decisions 1195, 1291, 2303): + /// the VM reports the registration, the registry gives it a row starting at the file's + /// value when the file carries one, a write to it dirties the config, and the save writes it + /// as a diff against the addon's default. Before the registry the write reached the VM's + /// table and nothing dirtied, so an addon-only change was saved only if something else + /// happened to move. + #[test] + fn an_addon_row_persists_like_the_clients_own() { + let mut cvars = registry(&[("myAddonKnob", "3")]); + assert_eq!( + cvars.orphans(), + vec![("myAddonKnob".to_string(), "3".to_string())], + "unclaimed until the addon declares it — the VM's saved base" + ); + cvars.learn_addon_row("myAddonKnob", "1"); + assert_eq!( + cvars.get("myAddonKnob"), + Some("3"), + "starts at the saved value" + ); + assert_eq!(cvars.default_of("myAddonKnob"), Some("1")); + assert!(cvars.orphans().is_empty()); + cvars.learn_addon_row("myAddonKnob", "9"); + assert_eq!( + cvars.default_of("myAddonKnob"), + Some("1"), + "a re-declaration is a no-op" + ); + assert!(!cvars.dirty); + assert_eq!(cvars.set_from_vm("myAddonKnob", "5"), SetOutcome::Changed); + assert!(cvars.dirty, "an addon-only change is a change"); + assert_eq!( + cvars.compose().get("myAddonKnob").map(String::as_str), + Some("5") + ); + cvars.set_from_vm("myAddonKnob", "1"); + assert!( + !cvars.compose().contains_key("myAddonKnob"), + "back at the addon's default, it leaves the diff" + ); + // A newer build's key stays an orphan and rides through the save verbatim. + let cvars = registry(&[("FutureKnob", "3")]); + assert_eq!( + cvars.compose().get("FutureKnob").map(String::as_str), + Some("3") + ); + } + + /// **A session-owned row answers the env and never reaches the file**: the value the knob + /// is running at is what `GetCVar` answers, the file's own entry for the key is neither + /// applied nor rewritten, and a player's write to it during the session still works — it + /// just stays out of the diff. + #[test] + fn a_session_owned_row_answers_the_env_and_never_reaches_the_file() { + let mut cvars = Cvars::default(); + cvars.own_for_session("uiScale", Some("1.2")); + cvars.load_file(BTreeMap::from([("uiScale".to_string(), "0.8".to_string())])); + assert_eq!( + cvars.get("uiScale"), + Some("1.2"), + "the env's, not the file's" + ); + assert!( + !cvars.has_events(), + "the knob already read the env — nothing to apply" + ); + assert_eq!(cvars.set("uiScale", "1.4"), SetOutcome::Changed); + assert_eq!( + cvars.compose().get("uiScale").map(String::as_str), + Some("0.8"), + "the file keeps what it said" + ); + // A lever whose resource is absent still marks the row, so the file cannot apply over + // the env — the registered default stands as the truth. + cvars.own_for_session("farclip", None); + cvars.load_file(BTreeMap::from([("farclip".to_string(), "500".to_string())])); + assert_eq!(cvars.get("farclip"), Some("350")); + assert!(cvars.is_session_owned("FARCLIP")); + } + + /// **The loaded file reaches a knob synchronously, before anything ordered after + /// [`CvarLoad`]** — the observers fire inside the exclusive load, which is what lets the + /// camera read `gxMultisample` at spawn (1629) and every other `Startup` reader of a knob + /// see the player's value rather than the default. + #[test] + fn the_loaded_file_reaches_a_knob_before_anything_ordered_after_cvar_load() { + use crate::local_state::test_env::{EnvGuard, ENV_LOCK}; + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = std::env::temp_dir().join(format!("benilla-cvar-load-{}", std::process::id())); + std::fs::remove_dir_all(&tmp).ok(); + let _c = EnvGuard::unset("WOW_CAPTURE"); + let _u = EnvGuard::unset("WOW_UI_SCALE"); + let _f = EnvGuard::unset("WOW_FARCLIP"); + let _d = EnvGuard::unset("WOW_CLUTTER_DENSITY"); + let _m = EnvGuard::unset("WOW_MSAA"); + let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap()); + crate::local_state::write_atomic( + &tmp.join("config.toml"), + "[cvars]\nMusicVolume = \"0.1\"\ngxMultisample = \"4\"\n", + ) + .unwrap(); + #[derive(Resource, Default)] + struct SeenAtStartup(Option<(f32, u32)>); + fn after_load( + sound: Res, + msaa: Res, + mut seen: ResMut, + ) { + seen.0 = Some((sound.music, msaa.samples)); + } + let mut app = cvar_app(); + app.init_resource::() + .add_systems(Startup, after_load.after(CvarLoad)); + app.update(); + assert_eq!( + app.world().resource::().0, + Some((0.1, 4)), + "both the plain row and the latched one are applied by the time CvarLoad is over" + ); + assert_eq!( + app.world().resource::().get("gxMultisample"), + Some("4"), + "a file value is applied, not staged: the reference's LoadFile runs before Register" + ); + std::fs::remove_dir_all(&tmp).ok(); + } + + /// **Every registered row has a reader** — the honest-tree rule (module doc, 1140), made + /// structural now that the registry has no central arm to answer `false` from. A row's + /// reader is a string literal naming it somewhere in the crate's code or `benilla-ui`'s (an + /// observer's arm in the lowercased spelling, a `cvars.get("…")`, a `const` the row is + /// registered through), or the row is named in [`LUA_ONLY`] with the stock file that reads + /// it. Test files are not readers. + #[test] + fn every_registered_row_has_a_reader_in_the_source() { + /// Rows whose only reader is the stock interface, by decision. + const LUA_ONLY: &[(&str, &str)] = &[ + ( + "statusBarText", + "TextStatusBar.lua reads it on CVAR_UPDATE (1140)", + ), + ( + "UberTooltips", + "GameTooltip's binding-line gate, stock and pfUI (1316)", + ), + ("gxApi", "pfUI's system tooltip names the backend (2151)"), + ( + "useUiScale", + "UIOptionsFrame.lua and OptionsFrame.lua branch on it to gate the uiScale slider", + ), + ]; + let app_src = crate::test_support::src_dir(); + let ui_src = app_src + .parent() + .and_then(std::path::Path::parent) + .expect("crates/") + .join("benilla-ui") + .join("src"); + let mut code = String::new(); + for file in crate::test_support::rust_files(&app_src) + .into_iter() + .chain(crate::test_support::rust_files(&ui_src)) + { + let rel = file.to_string_lossy().replace('\\', "/"); + if rel.ends_with("/cvars.rs") && rel.contains("benilla-app") || rel.contains("tests") { + continue; + } + let text = std::fs::read_to_string(&file).expect("source is readable"); + for line in text.lines() { + if !line.trim_start().starts_with("//") { + code.push_str(line); + code.push('\n'); + } + } + } + let mut orphans = Vec::new(); + for row in REGISTERED { + if LUA_ONLY.iter().any(|(n, _)| *n == row.name) { + continue; + } + let exact = format!("\"{}\"", row.name); + let lower = format!("\"{}\"", row.name.to_ascii_lowercase()); + if !(code.contains(&exact) || code.contains(&lower)) { + orphans.push(row.name); + } + } + assert!( + orphans.is_empty(), + "registered rows nothing in the source reads (an observer arm, a `cvars.get`, or a \ + `LUA_ONLY` entry with its stock reader): {orphans:?}" + ); + for (name, _) in LUA_ONLY { + assert!( + REGISTERED.iter().any(|r| r.name == *name), + "{name}: named Lua-only but not registered" ); } } + + /// The eight rows the reference latches that benilla registers, and no other — read off + /// `re/cvar/cvar-register-sites.tsv` (`flags` 2 or 3): the sound-init row, and the `gx*` + /// block `RestartGx` commits. `trilinear`/`anisotropic`/`farclip` register with `flags = 1` + /// there and apply live, so they are deliberately not here. + #[test] + fn the_latched_rows_are_the_references_own() { + let mut latched: Vec<&str> = REGISTERED + .iter() + .filter(|r| r.latched) + .map(|r| r.name) + .collect(); + latched.sort_unstable(); + assert_eq!( + latched, + vec![ + "SoundBufferSize", + "gxApi", + "gxColorBits", + "gxDepthBits", + "gxMultisample", + "gxResolution", + "gxVSync", + "gxWindow", + ] + ); + } } diff --git a/crates/benilla-app/src/death.rs b/crates/benilla-app/src/death.rs index 62ec55d8..91d16481 100644 --- a/crates/benilla-app/src/death.rs +++ b/crates/benilla-app/src/death.rs @@ -16,7 +16,7 @@ use benilla_ui::script::{DeathAction, DeathUiState, ScriptValue, UiScript}; use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer}; use crate::ui_action::Spells; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// Where our corpse is — the `MSG_CORPSE_QUERY` answer (decision 0308 §5). Raw WoW coords. #[derive(Debug, Clone, Copy, PartialEq)] @@ -718,7 +718,7 @@ impl Plugin for DeathPlugin { .add_systems( Update, ( - feed_death.before(UiInput), + feed_death.in_set(UiFeed), drain_death.after(UiInput), drive_death_look, // Before the feed, so the frame a session ends is already a frame the feed diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 8ab64db7..6ad9e665 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -231,6 +231,9 @@ impl PluginGroup for GamePlugins { // The CVar host (decision 0954): registration, knob sync, config.toml persistence. After // UiScriptPlugin only for reading order — its systems gate on the VM existing anyway. .add(crate::cvars::CvarPlugin) + // The console command registry (decision 2303): the reference's `ConsoleCommand` table, + // its four CVar commands and `help`; subsystems register their own from their plugins. + .add(crate::console::ConsolePlugin) // The key-binding engine (decision 0997): the chord→command dispatch every rebindable input // runs through, its persistence, and the Key Bindings window's capture seam. .add(crate::bindings::BindingsPlugin) @@ -519,6 +522,10 @@ pub(crate) mod schedule_tests { pub dependencies: Vec<(SystemKey, SystemKey)>, /// The systems that must run on the main thread (the VM's, the audio layer's). pub non_send: Vec, + /// The systems whose declared access includes the Lua VM — read off each system's + /// own access set, not off the conflicts it happens to have (a holder whose every VM + /// pair is declared would not show there). + pub holds_vm: HashSet, } impl Census { @@ -544,6 +551,13 @@ pub(crate) mod schedule_tests { ambiguity_detection: LogLevel::Warn, ..default() }); + // The systems' access is filled by `initialize`, which the build below would run + // anyway (bevy drains one list of uninitialized systems, once). Done first, so pass + // 1 can read it while the graph still holds the systems. + schedule.graph_mut().systems.initialize(world); + let vm = world + .components() + .get_resource_id(TypeId::of::()); // Pass 1, before the build: the graph still holds the systems. let graph = schedule.graph(); let set_conditions: HashMap<_, Vec> = graph @@ -560,7 +574,14 @@ pub(crate) mod schedule_tests { }) .collect(); let mut systems = HashMap::new(); + let mut holds_vm = HashSet::new(); for (key, system, conds) in graph.systems.iter() { + if let (Some(vm), Some(with_access)) = (vm, graph.systems.get(key)) { + let access = with_access.access.combined_access(); + if access.has_resource_read(vm) || access.has_resource_write(vm) { + holds_vm.insert(key); + } + } let mut sets = Vec::new(); let mut conditions: Vec = conds .iter() @@ -665,6 +686,7 @@ pub(crate) mod schedule_tests { classes, dependencies, non_send, + holds_vm, } }) } @@ -688,9 +710,11 @@ pub(crate) mod schedule_tests { /// /// - **A non-`Send` owner** — the Lua VM, the audio layer's handles. Their systems run on /// the main thread one at a time under any executor, so no order among them is a race. - /// *Which feed fires its Lua events first* is a real question — 2265 §A3(a)'s, answered - /// once for the `UiFeed` set, not per pair. Derived, not listed: every registration that - /// is not `Send + Sync`. + /// *Which feed fires its Lua events first* is a real question, answered by the `UiFeed` + /// phase (decision 2304): every push after the drain and before the tick, and among the + /// pushes an order declared where it matters (the chat cascade, the cooldown events), + /// registration order otherwise. Derived, not listed: every registration that is not + /// `Send + Sync`. /// - **A pure cache** — a read is a write because a miss records itself: the ask-once /// caches mark the key pending and send one query (`NameCache`, the GameObject /// templates, the page texts), the load-once caches build the entry (`WorldAssets`, @@ -704,6 +728,13 @@ pub(crate) mod schedule_tests { /// writers commute, and a drain's order against a writer is one frame of latency, never /// a loss. (2283's ten pairs were all this shape: a new verb drain against its siblings /// over the chat and error sinks.) + /// - **An exclusive edge** — a pair bevy reports with NO component list: one side is a + /// build-inserted `ApplyDeferred` sync point or an exclusive system, which conflicts on + /// `World` itself. Its order against a system it shares nothing with is immaterial by + /// construction (the build already placed the barrier after the commands it flushes and + /// before their dependents), and there is nothing to declare it against. Until 2304 an + /// empty list read as "VM only" vacuously, so that class rose and fell with the barrier + /// count; now it is named. /// - **A random stream** — `SoundKits`, which every sound system holds to play a kit: a /// decode cache, a per-kit last-variation memory and one xorshift stream. Any /// interleaving of draws is a valid draw, and that is the reference's own contract for @@ -798,6 +829,9 @@ pub(crate) mod schedule_tests { /// Which class explains this pair — `None` if it is actionable. pub fn class_of(&self, what: &[ComponentId]) -> Option<&'static str> { + if what.is_empty() { + return Some("exclusive"); + } if !what.iter().all(|id| self.explains(*id)) { return None; } @@ -919,7 +953,7 @@ pub(crate) mod schedule_tests { /// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with /// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is /// about a resource that commutes by construction, the claim belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 3_289; + const UPDATE_ACTIONABLE_CEILING: usize = 2_975; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { @@ -1122,7 +1156,7 @@ pub(crate) mod schedule_tests { .non_send .iter() .copied() - .filter(|k| holds(*k, &|id| Some(id) == c.classes.vm)) + .filter(|k| c.holds_vm.contains(k)) .collect(); let audio: Vec = c .non_send @@ -1180,6 +1214,137 @@ pub(crate) mod schedule_tests { eprintln!(" declared edges only: {w3} waves (every conflict ordered, everything Send)"); } + /// Why a system that holds the VM in `Update` and is ordered before the tick may stay OUT + /// of [`crate::ui_script::UiFeed`] — keyed by a suffix of the system's full name, with the + /// reason. Read beside [`every_vm_holder_in_update_declares_its_side_of_the_tick`]. + /// Empty at 2304: every holder ordered before the tick joined the phase (`ui_session:: + /// feed_interact_npc`, seated inside `WorldStage::Net` for decision 2022's reason, looked + /// like the one exception and is not — it writes a resource the unit feed reads, and + /// never holds the VM). + const OUTSIDE_THE_FEED_PHASE: &[(&str, &str)] = &[]; + + /// **Every system that holds the VM in `Update` declares its side of the tick** (decision + /// 2304). The VM ticks once a frame (`extract::tick_script`, in `UiInput`): a push the tick + /// must see rides `UiFeed`, which the plugin chains after the net drain and before the + /// tick; a drain of what the tick produced is `.after(UiInput)`. Read off the built graph, + /// so a membership through a parent set and an order through a chain both count. Three + /// things fail here: a holder with no declared path to or from the tick; one ordered + /// before the tick without riding the feed phase — so it may run before this frame's + /// packets land, the class 2265 §A3 counted as "ordered only against `UiInput`" — unless + /// it is argued in [`OUTSIDE_THE_FEED_PHASE`]; and a feed-phase member the graph does not + /// actually place after the drain and before the tick, the set's contract checked rather + /// than trusted. A row arguing a system the graph places elsewhere fails too. + #[test] + fn every_vm_holder_in_update_declares_its_side_of_the_tick() { + let mut app = headless_client(); + let c = census(&mut app, Update); + if !type_names_available(&c) { + eprintln!( + "skipped: this build carries no type names (bevy/debug rides with dev, 1451)" + ); + return; + } + let one = |suffix: &str| -> SystemKey { + let mut hits = c.systems.iter().filter(|(_, s)| s.name.ends_with(suffix)); + match (hits.next(), hits.next()) { + (Some((k, _)), None) => *k, + _ => panic!("exactly one system named `…{suffix}` in Update"), + } + }; + let tick = one("::extract::tick_script"); + let drain = one("::net::apply::apply_net_updates"); + let mut succ: HashMap> = HashMap::new(); + let mut pred: HashMap> = HashMap::new(); + for (a, b) in &c.dependencies { + succ.entry(*a).or_default().push(*b); + pred.entry(*b).or_default().push(*a); + } + let reach = |start: SystemKey, edges: &HashMap>| { + let mut seen = HashSet::new(); + let mut stack = vec![start]; + while let Some(n) = stack.pop() { + for m in edges.get(&n).into_iter().flatten() { + if seen.insert(*m) { + stack.push(*m); + } + } + } + seen + }; + let before_tick = reach(tick, &pred); + let after_tick = reach(tick, &succ); + let after_drain = reach(drain, &succ); + let mut holders: Vec = c.holds_vm.iter().copied().collect(); + holders.sort_by(|a, b| c.name(*a).cmp(c.name(*b))); + let (mut feed, mut post, mut argued) = (0, 0, 0); + let mut offenders = Vec::new(); + let mut stale = Vec::new(); + for k in &holders { + if *k == tick { + continue; + } + let s = &c.systems[k]; + let in_feed = s.sets.iter().any(|set| set == "UiFeed"); + let row = OUTSIDE_THE_FEED_PHASE + .iter() + .find(|(suffix, _)| s.name.ends_with(suffix)); + if in_feed { + feed += 1; + if !(after_drain.contains(k) && before_tick.contains(k)) { + offenders.push(format!( + "{}: in `UiFeed`, but the graph does not place it after the net drain and before the tick", + s.name + )); + } + } else if after_tick.contains(k) { + post += 1; + } else if before_tick.contains(k) { + if row.is_some() { + argued += 1; + continue; + } + offenders.push(format!( + "{}: ordered before the tick but not in `UiFeed` — it may run before this frame's packets land; `.in_set(UiFeed)`", + s.name + )); + } else { + offenders.push(format!( + "{}: no declared side of the tick — `.in_set(UiFeed)` for a push the tick must see this frame, `.after(UiInput)` for a drain of what the tick produced", + s.name + )); + } + if row.is_some() { + stale.push(format!( + "{}: argued in OUTSIDE_THE_FEED_PHASE but the graph places it {} — drop the row", + s.name, + if in_feed { + "in the feed phase" + } else { + "after the tick" + } + )); + } + } + for (suffix, _) in OUTSIDE_THE_FEED_PHASE { + if !holders.iter().any(|k| c.name(*k).ends_with(suffix)) { + stale.push(format!( + "`…{suffix}` is not a VM holder in Update — drop its row" + )); + } + } + eprintln!( + "VM holders in Update: {} — the tick, {feed} in the feed phase, {post} after the tick, {argued} argued outside the phase", + holders.len() + ); + assert!( + offenders.is_empty(), + "these systems hold the VM in `Update` without declaring their side of the tick \ + (decision 2304):\n {}", + offenders.join("\n ") + ); + assert!(stale.is_empty(), "stale rows:\n {}", stale.join("\n ")); + } + /// The consumer markers 2220's census used: a system that holds the VM and does one of /// these to host state has spent something this frame that nothing can re-spend. const CONSUMES: &[&str] = &["fire_event(", "mem::take(", ".drain("]; diff --git a/crates/benilla-app/src/game_tip.rs b/crates/benilla-app/src/game_tip.rs index eab8f590..358ce6da 100644 --- a/crates/benilla-app/src/game_tip.rs +++ b/crates/benilla-app/src/game_tip.rs @@ -268,12 +268,24 @@ pub(crate) fn tip_bundle() -> impl Bundle { pub(crate) struct GameTipPlugin; +/// The tip rows' change callback (decision 2303): the switch, and the cursor — which is not a +/// preference: a hand-edited or downgraded value lands here verbatim and [`raise`] clamps it, +/// which is the reference's own tolerance (`0x46b682`). +pub(crate) fn on_cvar(ev: On, mut setting: ResMut) { + match ev.key().as_str() { + "showgametips" => setting.show = ev.flag(), + "gametip" => setting.next = ev.num() as i64, + _ => {} + } +} + impl Plugin for GameTipPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() - // The CVar mirror, init'd HERE and not by the CVar host: `KnobParams` takes it as a - // plain `ResMut`, so a build that registers the row without the resource panics the - // first `load_config` — which is what a live run caught and no unit test could, the + // The CVar knob, init'd HERE and not by the CVar host: [`on_cvar`] takes it as a + // plain `ResMut`, so a build that registers the row without the resource panics on + // the first write — which is what a live run caught and no unit test could, the // test harness having its own `init_resource` chain. .init_resource::() .add_systems( @@ -330,9 +342,9 @@ fn drive_game_tip( windows: Query<&Window>, assets: Res, mut commands: Commands, - // The cursor persists through the VM's table, not the knob — see `cvars::write_host_cvar`. - script: Option>, - mut persist: ResMut, + // The cursor persists through the registry, not the knob alone: a host write there is what + // the file is composed from and what the VM's mirror learns (decision 2303). + mut cvars: ResMut, ) { let Some(edge) = screen.take_tip_edge() else { return; @@ -356,9 +368,7 @@ fn drive_game_tip( // is `EnterWorld`'s own bookkeeping and belongs to the PICK, not to the draw: it stands // even on a frame the paint below cannot complete. setting.next = i64::from(next); - if let Some(mut script) = script { - crate::cvars::write_host_cvar(&mut script, &mut persist, "gameTip", &next.to_string()); - } + cvars.set("gameTip", &next.to_string()); } // The text is laid out once per raise, not per frame, exactly as the reference does. @@ -573,7 +583,7 @@ mod tests { app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default())); crate::text_reshape::harness::add_text_plugins(&mut app); app.add_systems(PostUpdate, bevy::text::detect_text_needs_rerender::); - app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.insert_resource(GameTips { catalog: GameTipsCatalog::from_tips(vec![ diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index d9ad2faf..67a5107c 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -58,6 +58,7 @@ mod chat_bubble; mod chr_classes; mod cinematic; mod combat_text; +mod console; mod cooldowns; mod crash; mod creature_anim; diff --git a/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs index 802bbec3..234f5019 100644 --- a/crates/benilla-app/src/login/mod.rs +++ b/crates/benilla-app/src/login/mod.rs @@ -95,6 +95,11 @@ impl Plugin for LoginPlugin { ) .chain() .before(crate::glue::GlueVisuals) + // After the UI tick: one member holds the VM (`answer_dialog` writes + // into it), and every VM holder in `Update` declares its side of the + // tick (decision 2304). A glue screen has no push the tick must see, + // so the whole chain takes the drain side. + .after(crate::ui_script::UiInput) .run_if(in_state(ClientState::Login)), (smoke::debug_login_smoke, screen::debug_login_shot), ) @@ -156,7 +161,8 @@ impl LoginIntent { /// the channel to the parked IO thread, the abandon generation a Cancel bumps, and — since /// decision 1667 — the realmlist it dials. /// -/// A bundle rather than four parameters, for `cvars::KnobParams`' reason: adding the realmlist put +/// A bundle rather than four parameters, for the reason the CVar host's old knob bundle had +/// (retired by 2303): adding the realmlist put /// [`login_input`] at **seventeen** parameters, one past Bevy's ceiling, and the three systems /// that submit were already re-typing the same four names. Now a submit is one call on one param, /// and the next thing an attempt needs is one field here instead of a fourth signature to widen. diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index 59bce3f9..989e04a0 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -238,6 +238,23 @@ pub(crate) struct MinimapZoom { pub(crate) inside: u8, } +/// The two zoom indices' change callback (1131, 2303): each index lands on its own field, +/// clamped exactly like the client's `set_zoom` (`0x6daa10`: clamp at 5) — the widget clamps +/// again on the way in, so a hand-edited level lands in range whichever path it takes. +pub(crate) fn on_cvar(ev: On, mut zoom: ResMut) { + match ev.key().as_str() { + "minimapzoom" => zoom.outdoor = zoom_index(ev.num()), + "minimapinsidezoom" => zoom.inside = zoom_index(ev.num()), + _ => {} + } +} + +/// A stored minimap zoom level → a valid index: truncate to int and clamp into +/// `[0, MINIMAP_ZOOM_LEVELS)`, the client's own `set_zoom` clamp. +fn zoom_index(v: f32) -> u8 { + v.clamp(0.0, f32::from(benilla_ui::widget::MINIMAP_ZOOM_LEVELS - 1)) as u8 +} + impl Default for MinimapZoom { fn default() -> Self { Self { @@ -1058,6 +1075,7 @@ pub(crate) struct MinimapPlugin; impl Plugin for MinimapPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .init_resource::() .init_resource::() @@ -1086,13 +1104,16 @@ impl Plugin for MinimapPlugin { composite::drive_composite.after(UiQuadAppend), // Before the script tick, so a zoom button pressed this frame routes to the // indoor/outdoor index that matches where the player actually is. - feed_minimap_inside.before(crate::ui_script::UiInput), + feed_minimap_inside.in_set(crate::ui_script::UiFeed), // Before the script tick, so an addon's OnUpdate steers off this frame's // heading rather than last frame's. - feed_minimap_player_facing.before(crate::ui_script::UiInput), - // Before the emit that reads `MinimapAssets::mask`, so a mask set this frame - // is the one this frame draws with. - feed_minimap_mask.before(UiQuadAppend), + feed_minimap_player_facing.in_set(crate::ui_script::UiFeed), + // After the tick that can call `Minimap:SetMaskTexture`, before the emit that + // reads `MinimapAssets::mask`: a mask set this frame is the one this frame + // draws with. + feed_minimap_mask + .after(crate::ui_script::UiInput) + .before(UiQuadAppend), // Before the script tick, and after the containment verdict it reads: the // `MINIMAP_PING` event and `Minimap:GetPingPosition()`'s value land in the // same tick, on a ping the renderer already drew at the end of last frame. @@ -1103,16 +1124,16 @@ impl Plugin for MinimapPlugin { // Gated, the latch simply waits for the first frame with an interface. ping::drive_minimap_ping .after(feed_minimap_inside) - .before(crate::ui_script::UiInput) + .in_set(crate::ui_script::UiFeed) .run_if(crate::ui_script::ingame_ui_up), // Before the script tick, so GameTimeFrame's OnUpdate reads this frame's // minute, not last frame's. - feed_game_time.before(crate::ui_script::UiInput), + feed_game_time.in_set(crate::ui_script::UiFeed), // After the world-mouseover drive (UnitFeed): a same-frame world-hover→blip // transition must end with the blip tooltip shown, not the fade. blips::drive_blip_tooltip .after(crate::ui_unit::UnitFeed) - .before(crate::ui_script::UiInput), + .in_set(crate::ui_script::UiFeed), ), ); } diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs index 0d94a1d4..30227b49 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -801,8 +801,20 @@ pub(crate) struct NameplatesPlugin; #[derive(Resource)] struct NameAnchorTrace(bool); +/// The overhead-name rows' change callback (decision 2303): the name trio and the guild line. +pub(crate) fn on_cvar(ev: On, mut names: ResMut) { + match ev.key().as_str() { + "unitnameplayer" => names.player = ev.flag(), + "unitnamenpc" => names.npc = ev.flag(), + "unitnameown" => names.own = ev.flag(), + "unitnameplayerguild" => names.player_guild = ev.flag(), + _ => {} + } +} + impl Plugin for NameplatesPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.insert_resource(NameAnchorTrace( std::env::var_os("WOW_PROBE_NAME_TRACE").is_some(), )) diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 0bcf9d56..b4b8b961 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -27,11 +27,13 @@ use crossbeam_channel::{Receiver, Sender}; use benilla_world::schedule::WorldStage; mod apply; +pub(crate) mod handlers; pub(crate) mod io; mod motion; pub(crate) use apply::apply_net_updates; use apply::tag_self_player; +pub(crate) use handlers::NetHandlerApp; // The per-frame motion model lives in [`motion`]: `RemoteMotion`/`Spline` are re-exported for the // crate (the animation selector reads them); the integration systems + pose helpers stay `pub(super)` diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 8bb832b6..252cecb9 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -14,7 +14,6 @@ use super::{ }; mod anim; -mod auction; mod chat; mod combat; mod combat_chat; @@ -86,11 +85,28 @@ fn addressed_store<'a>( // ── The per-frame bridge systems ───────────────────────────────────────────────────────────────── -/// Drain the inbound event channel and mutate real ECS entities: spawn on create, move existing, -/// despawn on remove, attach/clear movement splines, and surface teleport/worldport/clock changes. -pub(crate) fn apply_net_updates( +/// The drain: take this frame's events off the channel and run them, **in packet order**, through +/// the two halves of the dispatch (decision 2305) — first the kinds the `match` below still owns, +/// through [`apply_unpeeled`], then the kinds a subsystem has claimed in the handler table +/// ([`super::handlers`]), each handler a one-shot system over this exclusive world. One frame, +/// packet order, before anything else in [`benilla_world::schedule::WorldStage::Net`] runs: the +/// property 0006 built and 2265 said every split must keep. +pub(crate) fn apply_net_updates(world: &mut World) { + let events: Vec = world.resource::().0.try_iter().collect(); + super::handlers::dispatch(world, events, |world, unclaimed| { + if let Err(e) = world.run_system_cached_with(apply_unpeeled, unclaimed) { + panic!("the drain's dispatch match did not run: {e}"); + } + }); +} + +/// The dispatch `match` — every kind no subsystem has claimed yet (2305's migration runs one +/// window family at a time out of here into its own module's handlers). Mutates real ECS +/// entities: spawn on create, move existing, despawn on remove, attach/clear movement splines, +/// and surface teleport/worldport/clock changes; parks the rest into window state. +fn apply_unpeeled( + In(events): In>, mut commands: Commands, - events: Res, net_commands: Res, mut index: ResMut, mut self_guid: ResMut, @@ -233,7 +249,6 @@ pub(crate) fn apply_net_updates( mut tabard, mut poi_marker, mut inspect_honor, - mut auction_open, mut ping, mut gm_ticket, mut registrar, @@ -295,7 +310,7 @@ pub(crate) fn apply_net_updates( } }; } - for ev in events.0.try_iter() { + for ev in events { match ev { SessionEvent::LoginStage { stage } => session::login_stage(stage, &mut login_stages), SessionEvent::LoginQueued { position, realm } => { @@ -375,7 +390,6 @@ pub(crate) fn apply_net_updates( &mut mail_open, &mut mail_pending, &mut trade_session, - &mut auction_open, &mut bank_open, &mut duel, &mut social, @@ -1889,41 +1903,6 @@ pub(crate) fn apply_net_updates( SessionEvent::NextMailTime { seconds } => { mail::next_mail_time(seconds, &mut mail_pending) } - // The auction house arc (decision 1511 P1): the hello reply OPENS the window — our - // send does not — and the three list results fill the three tabs. - SessionEvent::AuctionHello { - auctioneer, - house_id, - } => auction::auction_hello(auctioneer, house_id, &mut auction_open), - SessionEvent::AuctionCommandResult { - auction_id, - action, - error, - tail, - } => { - auction::auction_command_result(auction_id, action, error, &tail, &mut auction_open) - } - SessionEvent::AuctionListResult { - auctions, - total_count, - } => auction::auction_list_result(auctions, total_count, &mut auction_open), - SessionEvent::AuctionOwnerListResult { - auctions, - total_count, - } => auction::auction_owner_list_result(auctions, total_count, &mut auction_open), - SessionEvent::AuctionBidderListResult { - auctions, - total_count, - } => auction::auction_bidder_list_result(auctions, total_count, &mut auction_open), - SessionEvent::AuctionBidderNotification(n) => { - auction::auction_bidder_notification(&n, &mut auction_open) - } - SessionEvent::AuctionOwnerNotification(n) => { - auction::auction_owner_notification(&n, &mut auction_open) - } - SessionEvent::AuctionRemovedNotification { item_entry, .. } => { - auction::auction_removed_notification(item_entry, &mut auction_open) - } // The player-trade arc (decision 0592 P1): the status packet drives the open/accept/close // state machine, the extended snapshot replaces one side's item/gold — both into the // `TradeSession` the trade feed (`crate::ui_trade`) reads. @@ -1939,6 +1918,15 @@ pub(crate) fn apply_net_updates( SessionEvent::WorldStates { scope, states } => { world::world_states(scope, states, &mut world_states) } + // A kind with no arm here is one a subsystem has claimed in the handler table + // (decision 2305) — routed there by the drain, so it never reaches this match — or a + // new kind nobody handles, which `every_session_event_kind_has_one_owner` names at + // test time and this names at run time: the reference discards an unregistered + // opcode in silence; this client says so. + other => error!( + "net: {:?} reached the dispatch match with no arm — neither peeled nor handled", + benilla_protocol::SessionEventKind::from(&other) + ), } } // Flush the staged descriptor seeds/deltas onto the entities born this drain (now spawned by the diff --git a/crates/benilla-app/src/net/apply/params.rs b/crates/benilla-app/src/net/apply/params.rs index 4adcbf4d..0396037d 100644 --- a/crates/benilla-app/src/net/apply/params.rs +++ b/crates/benilla-app/src/net/apply/params.rs @@ -222,9 +222,6 @@ pub(crate) struct WindowStores<'w> { /// another player's honor numbers, so the reply parks here and `crate::ui_honor` pushes it /// into the pane and fires `INSPECT_HONOR_UPDATE`. pub inspect_honor: ResMut<'w, crate::ui_honor::InspectHonor>, - /// The auctioneer session (decision 1511) — the hello reply opens it, the three list results - /// fill it, and `crate::ui_auction` feeds it to the window. - pub auction_open: ResMut<'w, crate::ui_auction::AuctionOpen>, /// The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the world /// point here and the minimap renderer derives everything else from it. pub ping: ResMut<'w, crate::minimap::MinimapPing>, diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs index b8d221f5..1544b7f6 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/apply/session.rs @@ -194,7 +194,6 @@ pub(super) fn disconnected( mail: &mut MailOpen, mail_pending: &mut crate::ui_mail::MailPending, trade: &mut crate::ui_trade::TradeSession, - auction: &mut crate::ui_auction::AuctionOpen, bank: &mut crate::ui_bank::BankOpen, duel: &mut crate::ui_duel::DuelState, social: &mut crate::ui_social::SocialState, @@ -263,10 +262,6 @@ pub(super) fn disconnected( // The arrival countdown is login-scoped (decision 0544 P3): a fresh login re-queries // `MSG_QUERY_NEXT_MAIL_TIME` at world-enter, so nothing carries over across a reconnect. *mail_pending = crate::ui_mail::MailPending::default(); - // An open auction house dies with the socket (decision 1511): every auction command - // re-validates the auctioneer server-side, so a session that survived a reconnect would be a - // window whose every button silently failed. - auction.clear_session(); // An open trade dies with the socket too (decision 0592) — the reconnect starts with no trade. trade.clear_session(); // The bank window dies with the socket (decision 0604) — a reconnect re-opens via the banker. diff --git a/crates/benilla-app/src/net/handlers.rs b/crates/benilla-app/src/net/handlers.rs new file mode 100644 index 00000000..96b54742 --- /dev/null +++ b/crates/benilla-app/src/net/handlers.rs @@ -0,0 +1,329 @@ +//! The inbound **handler table** (decision 2305) — the reference's own shape for the wire's +//! arrival side, and the cut 2265 §A1 asked for between the net bridge and the game windows. +//! +//! The real client keeps an opcode → handler table inside `NetClient` (`+0x74`, 828 slots; 387 +//! registrations by 37 subsystem clusters — wow-re `net.md`) and its dispatcher `0x537aa0` knows +//! none of them: it looks the opcode up and calls what it finds, **in packet order**, discarding an +//! unregistered opcode in silence. Here the table is [`NetHandlers`]: a [`SessionEventKind`] → +//! handlers map that every subsystem fills for itself through [`NetHandlerApp::net_handler`], and +//! the drain ([`super::apply_net_updates`]) is exclusive over the world so the handlers — ordinary +//! systems taking `In`, registered as one-shots — run one after another in the +//! order the packets arrived, each seeing what the one before it did. +//! +//! **Why not one typed `Message` per family with a reader system each** (2265's sketch): a +//! reader per family runs *after* the whole drain, so two families' packets interleaved in one +//! frame are handled family by family, not in packet order — a chat line and a combat-log line +//! that arrived in one drain would swap. The table keeps the one property 2265 said must survive +//! any split: one frame, packet order, before anything else runs. +//! +//! **The migration.** The dispatch `match` in `apply.rs` still owns every kind no subsystem has +//! claimed. One drain runs in two halves: the unclaimed events through the match first, in packet +//! order, then the claimed ones through the table, in packet order — so a window handler always +//! sees this frame's object updates, whatever the interleaving. A kind is owned by exactly one of +//! the two, checked on the built app by `every_session_event_kind_has_one_owner`; the one +//! exception is [`BROADCAST`], a session-end the match still handles and peeled windows also listen +//! to, which reaches both. When the last family leaves the match, the match, the halves and the +//! broadcast list go with it. + +use std::collections::HashMap; + +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::ecs::system::SystemId; +use bevy::prelude::*; + +/// One registered handler: the one-shot system and the name it registers under (for the census). +type Handler = (SystemId>, &'static str); + +/// The table. Filled at plugin build by every subsystem that answers a packet; read by the drain. +#[derive(Resource, Default)] +pub(crate) struct NetHandlers { + by_kind: HashMap>, +} + +/// The kinds the dispatch match still owns **and** peeled subsystems listen to — a session end, +/// which every window that dies with the socket answers for itself. Cloned to the table after the +/// match has run. Empty once the session family itself is peeled. +pub(crate) const BROADCAST: &[SessionEventKind] = &[SessionEventKind::Disconnected]; + +impl NetHandlers { + /// Is there at least one handler for this kind? + pub(crate) fn handles(&self, kind: SessionEventKind) -> bool { + self.by_kind.contains_key(&kind) + } + + /// Every kind with a handler, with the handlers' names in registration order — the owner + /// test's view of the table. + #[cfg(test)] + pub(crate) fn census(&self) -> std::collections::BTreeMap> { + self.by_kind + .iter() + .map(|(k, v)| (*k, v.iter().map(|(_, n)| *n).collect())) + .collect() + } + + fn push(&mut self, kind: SessionEventKind, handler: Handler) { + self.by_kind.entry(kind).or_default().push(handler); + } + + /// Run every handler registered for the event's kind, in registration order, each with the + /// event (cloned for all but the last). + fn run(&self, world: &mut World, ev: SessionEvent) { + let Some(list) = self.by_kind.get(&SessionEventKind::from(&ev)) else { + return; + }; + let Some(((last_id, last_name), rest)) = list.split_last() else { + return; + }; + for (id, name) in rest { + call(world, *id, name, ev.clone()); + } + call(world, *last_id, last_name, ev); + } +} + +fn call(world: &mut World, id: SystemId>, name: &str, ev: SessionEvent) { + if let Err(e) = world.run_system_with(id, ev) { + // A registered handler that cannot run is a bug in the registering plugin (a missing + // resource, most likely), never the wire's fault — loud, so the smoke gate sees it. + error!("net: handler `{name}` did not run: {e}"); + } +} + +/// Registering a packet handler from a plugin: `app.net_handler(SessionEventKind::X, on_x)`, +/// where `on_x` is an ordinary system taking `In` and whatever it needs. Several +/// handlers may answer one kind; they run in registration order. +pub(crate) trait NetHandlerApp { + fn net_handler( + &mut self, + kind: SessionEventKind, + handler: impl IntoSystem, (), M> + 'static, + ) -> &mut Self; +} + +impl NetHandlerApp for App { + fn net_handler( + &mut self, + kind: SessionEventKind, + handler: impl IntoSystem, (), M> + 'static, + ) -> &mut Self { + let name = std::any::type_name_of_val(&handler); + let id = self.world_mut().register_system(handler); + self.world_mut() + .get_resource_or_init::() + .push(kind, (id, name)); + self + } +} + +/// One drain's dispatch: the events the table does not own go through `unclaimed` first — the +/// dispatch match, in packet order — then the owned ones run through the table, in packet order. +/// A [`BROADCAST`] kind reaches both. +pub(crate) fn dispatch( + world: &mut World, + events: Vec, + unclaimed: impl FnOnce(&mut World, Vec), +) { + let handlers = world.remove_resource::().unwrap_or_default(); + let mut to_match = Vec::with_capacity(events.len()); + let mut to_table = Vec::new(); + for ev in events { + let kind = SessionEventKind::from(&ev); + if !handlers.handles(kind) { + to_match.push(ev); + } else if BROADCAST.contains(&kind) { + to_table.push(ev.clone()); + to_match.push(ev); + } else { + to_table.push(ev); + } + } + unclaimed(world, to_match); + for ev in to_table { + handlers.run(world, ev); + } + world.insert_resource(handlers); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What ran, in order — a handler's name and the event it saw. + #[derive(Resource, Default)] + struct Log(Vec); + + fn on_queued(In(ev): In, mut log: ResMut) { + let SessionEvent::LoginQueued { position, .. } = ev else { + panic!("the table routes by kind"); + }; + log.0.push(format!("queued:{}", position.unwrap_or(0))); + } + + fn on_logged_out(In(ev): In, mut log: ResMut) { + assert!(matches!(ev, SessionEvent::LoggedOut)); + log.0.push("logged_out".into()); + } + + fn on_logged_out_too(In(_): In, mut log: ResMut) { + log.0.push("logged_out_too".into()); + } + + fn on_disconnected(In(ev): In, mut log: ResMut) { + let SessionEvent::Disconnected { reason, .. } = ev else { + panic!("the table routes by kind"); + }; + log.0.push(format!("disconnected:{reason}")); + } + + fn app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).init_resource::(); + app + } + + fn queued(position: u32) -> SessionEvent { + SessionEvent::LoginQueued { + position: Some(position), + realm: None, + } + } + + fn run(app: &mut App, events: Vec) -> Vec { + dispatch(app.world_mut(), events, |world, unclaimed| { + let mut log = world.resource_mut::(); + for ev in unclaimed { + log.0 + .push(format!("match:{:?}", SessionEventKind::from(&ev))); + } + }); + std::mem::take(&mut app.world_mut().resource_mut::().0) + } + + #[test] + fn owned_kinds_run_in_packet_order_after_the_match_has_run_the_rest() { + let mut app = app(); + app.net_handler(SessionEventKind::LoginQueued, on_queued) + .net_handler(SessionEventKind::LoggedOut, on_logged_out); + let log = run( + &mut app, + vec![ + queued(1), + SessionEvent::LoginStage { + stage: benilla_protocol::LoginStage::Connecting, + }, + SessionEvent::LoggedOut, + queued(2), + ], + ); + assert_eq!( + log, + vec!["match:LoginStage", "queued:1", "logged_out", "queued:2"] + ); + } + + #[test] + fn several_handlers_on_one_kind_run_in_registration_order_each_with_the_event() { + let mut app = app(); + app.net_handler(SessionEventKind::LoggedOut, on_logged_out) + .net_handler(SessionEventKind::LoggedOut, on_logged_out_too); + let log = run(&mut app, vec![SessionEvent::LoggedOut]); + assert_eq!(log, vec!["logged_out", "logged_out_too"]); + assert_eq!( + app.world().resource::().census()[&SessionEventKind::LoggedOut].len(), + 2 + ); + } + + #[test] + fn a_broadcast_kind_reaches_the_match_and_the_table() { + let mut app = app(); + app.net_handler(SessionEventKind::Disconnected, on_disconnected); + let log = run( + &mut app, + vec![SessionEvent::Disconnected { + reason: "socket".into(), + end: benilla_protocol::SessionEnd::Lost, + }], + ); + assert_eq!(log, vec!["match:Disconnected", "disconnected:socket"]); + } + + #[test] + fn with_no_table_at_all_everything_goes_to_the_match() { + let mut app = app(); + let log = run(&mut app, vec![SessionEvent::LoggedOut, queued(0)]); + assert_eq!(log, vec!["match:LoggedOut", "match:LoginQueued"]); + assert!(app.world().resource::().census().is_empty()); + } + + /// **Every kind has exactly one owner** — the dispatch match in `net/apply.rs` or the table, + /// read off the built client — and the [`BROADCAST`] rows are the only kinds in both. A kind + /// neither owns is a packet the client decodes and then drops on the floor. + #[test] + fn every_session_event_kind_has_one_owner() { + use std::collections::BTreeSet; + let source = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/net/apply.rs"), + ) + .expect("the drain's source"); + let stripped = regex_lite_strip_comments(&source); + let by_name: HashMap = SessionEventKind::all() + .map(|k| (format!("{k:?}"), k)) + .collect(); + let mut in_match: BTreeSet = BTreeSet::new(); + for token in stripped.split("SessionEvent::").skip(1) { + let name: String = token + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if let Some(k) = by_name.get(&name) { + in_match.insert(*k); + } + } + let mut app = crate::game_plugins::schedule_tests::headless_client(); + let table = app.world_mut().resource::().census(); + let in_table: BTreeSet = table.keys().copied().collect(); + let mut problems = Vec::new(); + for kind in SessionEventKind::all() { + let m = in_match.contains(&kind); + let t = in_table.contains(&kind); + let b = BROADCAST.contains(&kind); + match (m, t, b) { + (true, true, true) | (true, false, false) | (false, true, false) => {} + (true, true, false) => problems.push(format!( + "{kind:?}: owned by the match AND handled by {:?} — a peeled kind leaves the match (or is a BROADCAST row)", + table[&kind] + )), + (true, false, true) => problems.push(format!( + "{kind:?}: a BROADCAST row nobody listens to — drop the row" + )), + (false, true, true) => problems.push(format!( + "{kind:?}: a BROADCAST row the match no longer owns — drop the row" + )), + (false, false, _) => problems.push(format!( + "{kind:?}: decoded and dropped — no arm in the match, no handler in the table" + )), + } + } + eprintln!( + "session event kinds: {} — {} in the dispatch match, {} in the handler table ({} handlers), {} broadcast", + SessionEventKind::all().count(), + in_match.len(), + in_table.len(), + table.values().map(Vec::len).sum::(), + BROADCAST.len() + ); + assert!(problems.is_empty(), "{}", problems.join("\n")); + } + + /// `//` comments out, so a variant named in prose does not count as an arm. + fn regex_lite_strip_comments(s: &str) -> String { + s.lines() + .map(|l| match l.find("//") { + Some(i) => &l[..i], + None => l, + }) + .collect::>() + .join("\n") + } +} diff --git a/crates/benilla-app/src/perf/journal.rs b/crates/benilla-app/src/perf/journal.rs index 7b8aa5ce..8763453e 100644 --- a/crates/benilla-app/src/perf/journal.rs +++ b/crates/benilla-app/src/perf/journal.rs @@ -64,6 +64,15 @@ const JOURNAL_HEADER: &str = "t,x,y,z,mean_ms,p95_ms,streamed,entities,cpu_ms,ma gpu_ms,gpu_opaque,gpu_static,gpu_transp,gpu_glow,gpu_post,gpu_ui,\ gpu_other\n"; +/// The FPS journal switch's change callback (2008, 2303): a flag, the client's int-parse + +/// `!= 0`. The journal system reads the knob every frame, so the file opens on the next second +/// and closes the second it is turned off. +pub(crate) fn on_cvar(ev: On, mut journal: ResMut) { + if ev.is("fpsJournal") { + journal.0 = ev.flag(); + } +} + impl Plugin for FpsJournalPlugin { fn build(&self, app: &mut App) { // bevy's per-pass render diagnostics — the source of the GPU columns, and (under the @@ -72,6 +81,7 @@ impl Plugin for FpsJournalPlugin { // journal is exactly the build that has to carry it (2008). app.add_plugins(RenderDiagnosticsPlugin) .init_resource::() + .add_observer(on_cvar) .insert_resource(FpsJournal { env_path: std::env::var("WOW_FPS_JOURNAL") .ok() diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs index 5d63ce8c..7b198905 100644 --- a/crates/benilla-app/src/perf/mod.rs +++ b/crates/benilla-app/src/perf/mod.rs @@ -82,7 +82,9 @@ pub(crate) use clock::{process_cpu_secs, process_faults, system_cpu_ticks, threa pub(crate) use gpu::{GpuMsShared, WgpuCensusShared}; #[cfg(feature = "dev")] pub(crate) use hud::PerfHud; -pub(crate) use journal::{FpsJournalPlugin, FpsJournalSetting}; +pub(crate) use journal::FpsJournalPlugin; +#[cfg(test)] +pub(crate) use journal::{on_cvar, FpsJournalSetting}; #[cfg(feature = "dev")] pub(crate) use main_split::MainThreadSplit; diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index e35447f2..77160706 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -326,6 +326,8 @@ impl Plugin for PlayerPlugin { Update, world_focus::release_post_snap_hold.after(benilla_world::schedule::WorldStage::Stream), ); + app.add_observer(camera::on_cvar); + app.add_observer(camera_dynamics::on_cvar); app.init_resource::(); app.init_resource::(); app.init_resource::(); diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 4946891b..6995bce7 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -152,7 +152,9 @@ impl ZoomLimit { self.max = CAM_DIST_BASE_MAX * f; } - /// The live factor — what the CVar table and the config file carry. + /// The live factor — the inverse of [`Self::set_factor`], kept for the weld test (the + /// registry holds the string itself since 2303). + #[cfg(test)] pub(crate) fn factor(&self) -> f32 { self.max / CAM_DIST_BASE_MAX } @@ -173,6 +175,54 @@ const LOOK_SENSITIVITY: f32 = 0.003; /// reproduces the shipped feel exactly. pub(crate) const MOUSE_SPEED_RANGE: std::ops::RangeInclusive = 0.5..=1.5; +/// The camera rows' change callback (decision 2303) — the look, zoom and follow knobs. +pub(crate) fn on_cvar( + ev: On, + mut look: ResMut, + mut zoom: ResMut, + mut follow: ResMut, +) { + let v = ev.num(); + match ev.key().as_str() { + "mouseinvertpitch" => look.invert_pitch = v != 0.0, + // The 1.12 slider's own range; an off-grid hand-edit rides between stops, like the others. + "mousespeed" => { + look.sensitivity = v.clamp(*MOUSE_SPEED_RANGE.start(), *MOUSE_SPEED_RANGE.end()); + } + // The reference's `0x50b330` validator REJECTS an out-of-range value rather than clamping + // it: it prints `Value out of range (%f - %f)` and `CVar::Set` never stores, so the old + // value stands. That is a different posture from every clamping row, and it is the + // faithful one — a script writing 1e9 gets a refusal, not a silently pinned camera. + "camerayawmovespeed" | "camerapitchmovespeed" => { + if !CAMERA_SPEED_RANGE.contains(&v) { + warn!( + "cvar {}: value out of range ({} - {}) — ignored", + ev.name, + CAMERA_SPEED_RANGE.start(), + CAMERA_SPEED_RANGE.end() + ); + return; + } + if ev.is("cameraYawMoveSpeed") { + look.yaw_speed = v; + } else { + look.pitch_speed = v; + } + } + "cameradistancemaxfactor" => zoom.set_factor(v), + // The three stops are 1 Smart / 2 Always / 3 Never; anything else reads as the registrar + // default rather than as a dead camera (`FollowStyle::from_cvar`). + "camerasmoothstyle" => follow.style = FollowStyle::from_cvar(v), + // Its sibling selector — the one the reference swaps in for the externally-driven states. + "camerasmoothtrackingstyle" => follow.tracking_style = FollowStyle::from_cvar(v), + // The auto-follow rate, clamped to 1.12's own AUTO_FOLLOW_SPEED slider range. + "camerayawsmoothspeed" => { + follow.yaw_speed = v.clamp(*FOLLOW_SPEED_RANGE.start(), *FOLLOW_SPEED_RANGE.end()); + } + _ => {} + } +} + /// **The mouse-look rate law, and the one place benilla's units are not the reference's.** /// /// The reference's own law is byte-VERIFIED (wow-re `world-click-drag-arbitration.md` §3.3): @@ -330,7 +380,9 @@ impl FollowStyle { } } - /// The CVar string this style is — the value the table and `config.toml` carry. + /// The CVar string this style is — the inverse of [`Self::from_cvar`], kept for the + /// round-trip test (the registry holds the string itself since 2303). + #[cfg(test)] pub(crate) fn cvar(self) -> &'static str { match self { Self::Never => "0", diff --git a/crates/benilla-app/src/player/camera_dynamics.rs b/crates/benilla-app/src/player/camera_dynamics.rs index ed15702f..6ce7c063 100644 --- a/crates/benilla-app/src/player/camera_dynamics.rs +++ b/crates/benilla-app/src/player/camera_dynamics.rs @@ -104,6 +104,30 @@ pub(crate) struct CameraOptions { pub(crate) water_collision: bool, } +/// The camera options' change callback (2149, 2303). The numeric rows take the value +/// straight: the reference's own validator on them is `0x50b330`'s range REFUSAL, which is the +/// camera-speed rows' business ([`super::camera::on_cvar`]), not these. +pub(crate) fn on_cvar(ev: On, mut opts: ResMut) { + let v = ev.num(); + match ev.key().as_str() { + "camerapivot" => opts.pivot = v != 0.0, + "camerawatercollision" => opts.water_collision = v != 0.0, + "camerapivotdxmax" => opts.pivot_dx_max = v, + "camerapivotdymin" => opts.pivot_dy_min = v, + "cameratargetsmoothspeed" => opts.target_smooth_speed = v, + "cameraterraintilt" => opts.terrain_tilt = v != 0.0, + "cameragroundsmoothspeed" => opts.ground_smooth_speed = v, + "cameraterraintilttimemin" => opts.tilt_time_min = v, + "cameraterraintilttimemax" => opts.tilt_time_max = v, + "camerabobbing" => opts.bobbing = v != 0.0, + "camerabobbinglramplitude" => opts.bob_lr_amplitude = v, + "camerabobbingudamplitude" => opts.bob_ud_amplitude = v, + "camerabobbingfrequency" => opts.bob_frequency = v, + "camerabobbingsmoothspeed" => opts.bob_smooth_speed = v, + _ => {} + } +} + impl Default for CameraOptions { fn default() -> Self { Self { diff --git a/crates/benilla-app/src/player/camera_view.rs b/crates/benilla-app/src/player/camera_view.rs index 2d09ec7d..d1601fd7 100644 --- a/crates/benilla-app/src/player/camera_view.rs +++ b/crates/benilla-app/src/player/camera_view.rs @@ -146,17 +146,6 @@ pub(crate) const VIEW_DEFAULTS: [[&str; 3]; VIEW_COUNT] = [ /// the client opens on `THIRD_PERSON_A`. pub(crate) const ACTIVE_VIEW_DEFAULT: &str = "1"; -/// True if `name` is one of this module's sixteen CVars (case-insensitively, like every CVar -/// lookup). [`crate::cvars::apply_to_knobs`] asks so a `SaveView` marks the config dirty and the -/// view is actually persisted; there is nothing to apply, because [`CameraViews`] is the writer. -pub(crate) fn is_view_cvar(name: &str) -> bool { - name.eq_ignore_ascii_case(CVAR_ACTIVE_VIEW) - || VIEW_CVARS - .iter() - .flatten() - .any(|n| n.eq_ignore_ascii_case(name)) -} - /// One view's pose, in **benilla's** units: yards, and radians with [`FlyCam::pitch`]'s /// positive-is-UP sign. `yaw` is an offset from the subject's facing (see the module header), so a /// view whose yaw is `0.0` is "directly behind" whichever way the character is pointing. @@ -270,17 +259,17 @@ impl CameraViews { /// Startup: seed the five views (and the live index) from `config.toml`. /// -/// Reads [`crate::cvars::CvarPersist::stored`] rather than the VM's table for the reason 1622's -/// remembered-character row does: the VM's CVar table is a per-VM `Update` seed and does not exist -/// yet, while the persisted values are a resource from `CvarLoad` onward and stay current across a -/// VM replacement (1291). An absent key is the shipped default, which is every first run. -fn load_saved_views(persist: Res, mut views: ResMut) { +/// Reads the registry ([`crate::cvars::Cvars::get`]) rather than the VM's table for the reason +/// 1622's remembered-character row does: the VM's mirror is a per-VM `Update` seed and does not +/// exist yet, while the registry is a resource from `CvarLoad` onward and outlives every VM +/// (2303). An absent key answers the shipped default, which is every first run. +fn load_saved_views(cvars: Res, mut views: ResMut) { for (view, slot) in views.views.iter_mut().enumerate() { // Each field is independently optional, like the pose file's two keys: an absent or // hand-mangled value costs that one number, not the whole view. let field = |i: usize| { - persist - .stored(VIEW_CVARS[view][i]) + cvars + .get(VIEW_CVARS[view][i]) .and_then(|s| s.trim().parse::().ok()) .filter(|v| v.is_finite()) }; @@ -294,8 +283,8 @@ fn load_saved_views(persist: Res, mut views: ResMut().ok()) .filter(|v| *v < VIEW_COUNT) { @@ -316,7 +305,11 @@ struct ViewTargets<'w, 's> { /// /// Ordered before [`super::control`] so a view taken this frame is the pose this frame's camera /// seat is computed from, exactly like the pose file's restore. -fn drain_view_requests(script: Option>, mut targets: ViewTargets) { +fn drain_view_requests( + script: Option>, + mut cvars: ResMut, + mut targets: ViewTargets, +) { let Some(mut script) = script else { return; }; @@ -399,10 +392,10 @@ fn drain_view_requests(script: Option>, mut targets: ViewTa } } - // Mirror into the CVar table so the change dirties `config.toml` and the view survives a - // restart — `set_cvar_engine` is the minimap-zoom pattern (the engine's own value moved, so - // the table follows AND queues the change). The resource stays authoritative either way, so a - // run with no VM at all (a bare test app) still has working views. + // Mirror into the registry so the change dirties `config.toml` and the view survives a + // restart — a host write (2303: the engine's own value moved, so the table follows and the + // VM's mirror learns it). The resource stays authoritative either way, so a run with no VM + // at all (a bare test app) still has working views. for view in write_slots { // A reset writes the reference's default STRING verbatim, not a re-rendered `%f` of it: // `crate::cvars`'s file is a diff against the registered default, and only the exact text @@ -415,11 +408,11 @@ fn drain_view_requests(script: Option>, mut targets: ViewTa } else { rendered[field].as_str() }; - script.set_cvar_engine(VIEW_CVARS[view][field], value); + cvars.set(VIEW_CVARS[view][field], value); } } if write_active { - script.set_cvar_engine(CVAR_ACTIVE_VIEW, &targets.views.current.to_string()); + cvars.set(CVAR_ACTIVE_VIEW, &targets.views.current.to_string()); } } @@ -451,6 +444,9 @@ pub(super) fn plugin(app: &mut App) { .add_systems( Update, drain_view_requests + // After the tick that queued them (`SetView`/`SaveView` are Lua's), before the + // control pass that seats the pose. + .after(crate::ui_script::UiInput) .before(super::control) // Capture parks the camera itself (`capture::probe_cam`), and `control` is gated // off there for the same reason; a queued view must not steal a parked pose. @@ -630,8 +626,8 @@ mod tests { } /// **The weld** ([`crate::cvars`]'s own convention): every one of the sixteen names is - /// registered, with the reference's default string, and nothing here has drifted from the - /// table `is_view_cvar` answers from. + /// registered, with the reference's default string. No observer watches them — this module + /// is their writer, and the registry persists a host write on its own (2303). #[test] fn the_view_cvars_are_registered_with_the_references_defaults() { let registered = |name: &str| { @@ -648,25 +644,8 @@ mod tests { Some(VIEW_DEFAULTS[view][field]), "{name} is not registered with its shipped default" ); - assert!( - is_view_cvar(name), - "{name} is not recognised as a view cvar" - ); - assert!( - is_view_cvar(&name.to_ascii_uppercase()), - "{name} is case-sensitive" - ); } } assert_eq!(registered(CVAR_ACTIVE_VIEW), Some(ACTIVE_VIEW_DEFAULT)); - assert!(is_view_cvar(CVAR_ACTIVE_VIEW)); - assert!( - !is_view_cvar("cameraDistanceMaxFactor"), - "the zoom ceiling is not a view" - ); - assert!( - !is_view_cvar("cameraDistanceE"), - "there are only five views" - ); } } diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index dc83c0c9..ca5e38ab 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -1204,11 +1204,19 @@ pub(crate) struct BoothFraming<'w> { /// Owns the portrait bake pipeline: the [`PortraitImages`] bridge + the per-slot off-screen booths. pub(crate) struct PortraitPlugin; +/// The body panes' half-rate switch's change callback (1444, 2303): a flag. +pub(crate) fn on_cvar(ev: On, mut rate: ResMut) { + if ev.is("boothHalfRate") { + rate.half = ev.flag(); + } +} + impl Plugin for PortraitPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .init_resource::() + .add_observer(on_cvar) .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/realm_select/mod.rs b/crates/benilla-app/src/realm_select/mod.rs index 722ac1f4..592b3894 100644 --- a/crates/benilla-app/src/realm_select/mod.rs +++ b/crates/benilla-app/src/realm_select/mod.rs @@ -374,7 +374,7 @@ fn apply_realm_policy( mut msgs: MessageReader, mut realms: ResMut, choice: Res, - persist: Res, + cvars: Res, ) { if !realms.env_read { realms.env_read = true; @@ -396,7 +396,11 @@ fn apply_realm_policy( if realms.shown { continue; } - let remembered = persist.stored(CVAR_REALM_NAME).map(str::to_string); + // The registered default is the empty string — a client that has never connected. + let remembered = cvars + .get(CVAR_REALM_NAME) + .filter(|s| !s.is_empty()) + .map(str::to_string); if realms.current.is_none() { realms.current = remembered.clone(); } diff --git a/crates/benilla-app/src/realmlist.rs b/crates/benilla-app/src/realmlist.rs index f0066348..d54e592f 100644 --- a/crates/benilla-app/src/realmlist.rs +++ b/crates/benilla-app/src/realmlist.rs @@ -29,8 +29,21 @@ use bevy::prelude::*; /// before `cvars::load_config` applies the saved value at `Startup` — `lib.rs` orders it there. pub(crate) struct RealmlistPlugin; +/// `realmList`'s change callback (1667, 2303): a string row — a value that is not an address +/// is consumed with a warn, and the resource keeps its truth. +pub(crate) fn on_cvar(ev: On, mut realmlist: ResMut) { + if !ev.is(CVAR_REALMLIST) { + return; + } + match normalize(&ev.new) { + Some(address) => realmlist.set(&address), + None => warn!("cvar realmList: unusable realmlist '{}' ignored", ev.new), + } +} + impl Plugin for RealmlistPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::(); } } diff --git a/crates/benilla-app/src/screenshot.rs b/crates/benilla-app/src/screenshot.rs index d6d8431a..35eb57a6 100644 --- a/crates/benilla-app/src/screenshot.rs +++ b/crates/benilla-app/src/screenshot.rs @@ -65,7 +65,7 @@ use bevy::tasks::IoTaskPool; use benilla_ui::script::UiScript; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// The reference's own file stem (`WoWScrnShot_`), kept: a player who knows what WoW screenshots /// are called finds the same names here, and so does every screenshot-organizing tool the @@ -255,7 +255,7 @@ impl Plugin for ScreenshotPlugin { app.init_resource::().add_systems( Update, ( - report_captures.before(UiInput), + report_captures.in_set(UiFeed), ask_for_captures .after(UiInput) .after(crate::bindings::BindingSet), diff --git a/crates/benilla-app/src/sound/mod.rs b/crates/benilla-app/src/sound/mod.rs index 969a19c7..13012951 100644 --- a/crates/benilla-app/src/sound/mod.rs +++ b/crates/benilla-app/src/sound/mod.rs @@ -524,8 +524,34 @@ fn world_audio_live( pub(crate) struct SoundPlugin; +/// The sound rows' change callback (decision 2303): the volumes clamp to `[0, 1]`, the enables +/// are the client's int-parse + `!= 0` — `SoundReverb`'s own parse is literally that +/// (`0x4574d0`: `setne al`). Writes only the arm it matched, so a `SoundConfig` change is a +/// sound setting moving and nothing else. +pub(crate) fn on_cvar(ev: On, mut sound: ResMut) { + let v = ev.num(); + match ev.key().as_str() { + "mastervolume" => sound.master = v.clamp(0.0, 1.0), + "soundvolume" => sound.sfx = v.clamp(0.0, 1.0), + "musicvolume" => sound.music = v.clamp(0.0, 1.0), + "ambiencevolume" => sound.ambience = v.clamp(0.0, 1.0), + "mastersoundeffects" => sound.enabled = v != 0.0, + "enablemusic" => sound.music_enabled = v != 0.0, + "enableambience" => sound.ambience_enabled = v != 0.0, + "enableerrorspeech" => sound.error_speech = v != 0.0, + "sound_enablesoundwhengameisinbg" => sound.background_sound = v != 0.0, + "soundreverb" => sound.reverb = v != 0.0, + "soundoutputlimiter" => sound.limiter = v != 0.0, + "soundlisteneratcharacter" => sound.listener_at_character = v != 0.0, + "emotesounds" => sound.emote_sounds = v != 0.0, + "soundzonemusicnodelay" => sound.zone_music_no_delay = v != 0.0, + _ => {} + } +} + impl Plugin for SoundPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); // Who gets sound: a run a human launched, and only that. The default posture is audible // (decision 1026 — `SoundConfig::muted` starts false), so the silence has to be opt-in by // the *automated* callers, both of which are unattended by construction: diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 274e9b3b..d0280f41 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -389,11 +389,25 @@ impl Default for ClickConfig { #[derive(Resource, Default)] pub(crate) struct AssistAttack(pub(crate) bool); +/// The targeting rows' change callback (decision 2303): two flags. +pub(crate) fn on_cvar( + ev: On, + mut click: ResMut, + mut assist: ResMut, +) { + match ev.key().as_str() { + "deselectonclick" => click.deselect_on_click = ev.flag(), + "assistattack" => assist.0 = ev.flag(), + _ => {} + } +} + /// Targeting: click-to-select + the ground selection ring. pub(crate) struct TargetPlugin; impl Plugin for TargetPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/text_filter.rs b/crates/benilla-app/src/text_filter.rs index 855cdbae..279fa3e0 100644 --- a/crates/benilla-app/src/text_filter.rs +++ b/crates/benilla-app/src/text_filter.rs @@ -595,8 +595,19 @@ fn load_text_filter_lists( pub(crate) struct TextFilterPlugin; +/// The two filter switches' change callback (decision 2303): flags — the reference's own +/// callbacks (`0x403570`, `0x4035b0`) mirror `SStrToInt(newValue)` into a global the same way. +pub(crate) fn on_cvar(ev: On, mut switches: ResMut) { + match ev.key().as_str() { + "profanityfilter" => switches.profanity = ev.flag(), + "spamfilter" => switches.spam = ev.flag(), + _ => {} + } +} + impl Plugin for TextFilterPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .init_resource::() .add_systems( diff --git a/crates/benilla-app/src/tutorial.rs b/crates/benilla-app/src/tutorial.rs index 67de1ef6..9f54ab19 100644 --- a/crates/benilla-app/src/tutorial.rs +++ b/crates/benilla-app/src/tutorial.rs @@ -35,7 +35,7 @@ use benilla_ui::script::{ScriptValue, UiScript}; use crate::char_select::ClientState; use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer}; use crate::player::Player; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// The popup cue `0x4b5390` plays on both legs (`0x846b68`), gated on `MasterSoundEffects` at /// the mixer like every SFX kit. @@ -599,7 +599,7 @@ impl Plugin for TutorialPlugin { // The gate costs nothing: the cascade still arms and still triggers, `fired` // simply waits, and the first frame with an interface delivers the set. feed_tutorials - .before(UiInput) + .in_set(UiFeed) .run_if(crate::ui_script::ingame_ui_up), drain_tutorials.after(UiInput), ), diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index f24a3b75..256dd71e 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -65,6 +65,13 @@ pub(crate) struct CooldownEvents; // `cast_send`, so a second send path cannot be written by accident (decision 0914). pub(crate) use cast_send::{CastCommit, CastLadder}; pub(crate) use cast_target::AutoSelfCast; + +/// `autoSelfCast`'s change callback (decision 2303): a flag. +pub(crate) fn on_cvar(ev: On, mut auto: ResMut) { + if ev.is("autoSelfCast") { + auto.0 = ev.flag(); + } +} pub(crate) use errors::{ attack_actor_blocked, attack_actor_refusal, keyed_line, keyed_line_s, reagent_totem_refusal, show_messages, ui_error_text, CastErrors, CastFail, Caster, FillArg, MessageSink, MountErrors, @@ -357,6 +364,7 @@ pub(crate) struct UiActionPlugin; impl Plugin for UiActionPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .init_resource::() .init_resource::() @@ -389,12 +397,11 @@ impl Plugin for UiActionPlugin { ranks::normalize_action_ranks .in_set(UnitFeed) .before(feed::feed_actions), - feed::feed_actions.in_set(UnitFeed).before(UiInput), + feed::feed_actions.in_set(UnitFeed), state::feed_action_state .in_set(UnitFeed) .in_set(CooldownEvents) - .after(feed::feed_actions) - .before(UiInput), + .after(feed::feed_actions), drain::drain_action_sets.after(UiInput), drain::drain_action_uses.after(UiInput), // The T binding (0997): the attack arm's twin door, after the dispatch wrote @@ -422,9 +429,7 @@ impl Plugin for UiActionPlugin { // the next frame's cursor drive reads the mode. The cursor pre-empt, the // right-press cancel, and the click commit register in the TARGET chain // (ordering against the classifier and the select click is theirs to own). - targeting::feed_targeting_to_vm - .in_set(UnitFeed) - .before(UiInput), + targeting::feed_targeting_to_vm.in_set(UnitFeed), targeting::drain_stop_targeting.after(UiInput), // The item half's commit (decision 0923) — the bag / paper-doll click seam's // `0x495d60`. A UI drain like the others: after the input pass, so a click diff --git a/crates/benilla-app/src/ui_auction/mod.rs b/crates/benilla-app/src/ui_auction/mod.rs index 1476f04f..f72e8add 100644 --- a/crates/benilla-app/src/ui_auction/mod.rs +++ b/crates/benilla-app/src/ui_auction/mod.rs @@ -24,7 +24,8 @@ //! refusal. The window opening clears the gate, so the first search is always allowed. (INTERIM, //! decision 1511 — pinned to the in-flight §5's TU-2.) //! -//! The net bridge ([`crate::net::apply::auction`]) fills [`AuctionOpen`] from the wire. Each frame +//! The packet handlers ([`net`], in the net handler table — decision 2305) fill [`AuctionOpen`] +//! from the wire. Each frame //! [`feed_auction`] resolves each [`AuctionListEntry`] to a Lua-facing row (name/quality/icon via //! the ask-once item-template cache + `ItemDisplayInfo.dbc`, seller via the ask-once name cache, //! the time-left bucket from the wire's milliseconds), applies the sort, pushes the snapshot, and @@ -49,13 +50,15 @@ use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; mod sort; use sort::SortStack; +mod net; + /// The browse query rate limit, in seconds. **VERIFIED** (wow-re §5 TU-2): the reference arms the /// gate with `tick + 0x1388` *after* the packet goes out, re-checks it inside the query itself, /// and its refusal path fires **nothing at all** — no event, no error. That silence is why the @@ -371,6 +374,7 @@ pub(crate) struct UiAuctionPlugin; impl Plugin for UiAuctionPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::().add_systems( Update, ( @@ -388,7 +392,7 @@ impl Plugin for UiAuctionPlugin { // packets set survive as wire re-asks either way. feed_auction .after(crate::ui_unit::UnitFeed) - .before(UiInput) + .in_set(UiFeed) .run_if(crate::ui_script::ingame_ui_up), drain_auction.after(UiInput), ), diff --git a/crates/benilla-app/src/net/apply/auction.rs b/crates/benilla-app/src/ui_auction/net.rs similarity index 70% rename from crates/benilla-app/src/net/apply/auction.rs rename to crates/benilla-app/src/ui_auction/net.rs index 1ac2fff7..52dff545 100644 --- a/crates/benilla-app/src/net/apply/auction.rs +++ b/crates/benilla-app/src/ui_auction/net.rs @@ -1,23 +1,120 @@ -//! The auction house's wire→session bridge (decision 1511 P1) — every `SMSG_AUCTION_*` lands here -//! and becomes state on [`AuctionOpen`], which [`crate::ui_auction::feed_auction`] turns into the -//! window's events on the next frame. Nothing here touches the VM: the feed owns the script. +//! The auction house's packet handlers (decision 1511 P1; in the net handler table since 2305 — +//! the first family out of the drain's dispatch match) — every `SMSG_AUCTION_*` lands here and +//! becomes state on [`AuctionOpen`], which [`super::feed_auction`] turns into the window's events +//! on the next frame. Nothing here touches the VM: the feed owns the script. use benilla_protocol::messages::{ auction_action, auction_error, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry, AuctionOwnerNotification, }; +use benilla_protocol::{SessionEvent, SessionEventKind}; use benilla_ui::script::{BIDDER, LIST, OWNER}; +use bevy::prelude::*; -use crate::ui_auction::{AuctionMessage, AuctionOpen}; +use super::{AuctionMessage, AuctionOpen}; +use crate::net::NetHandlerApp; + +/// Register the house's handlers — called from [`super::UiAuctionPlugin`]. One per `SMSG_AUCTION_*` +/// kind, plus the session-end listener. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::AuctionHello, on_hello) + .net_handler(K::AuctionCommandResult, on_command_result) + .net_handler(K::AuctionListResult, on_list_result) + .net_handler(K::AuctionOwnerListResult, on_owner_list_result) + .net_handler(K::AuctionBidderListResult, on_bidder_list_result) + .net_handler(K::AuctionBidderNotification, on_bidder_notification) + .net_handler(K::AuctionOwnerNotification, on_owner_notification) + .net_handler(K::AuctionRemovedNotification, on_removed_notification) + .net_handler(K::Disconnected, on_session_end); +} + +fn on_hello(In(ev): In, mut auction: ResMut) { + if let SessionEvent::AuctionHello { + auctioneer, + house_id, + } = ev + { + auction_hello(auctioneer, house_id, &mut auction); + } +} + +fn on_command_result(In(ev): In, mut auction: ResMut) { + if let SessionEvent::AuctionCommandResult { + auction_id, + action, + error, + tail, + } = ev + { + auction_command_result(auction_id, action, error, &tail, &mut auction); + } +} + +fn on_list_result(In(ev): In, mut auction: ResMut) { + if let SessionEvent::AuctionListResult { + auctions, + total_count, + } = ev + { + auction_list_result(auctions, total_count, &mut auction); + } +} + +fn on_owner_list_result(In(ev): In, mut auction: ResMut) { + if let SessionEvent::AuctionOwnerListResult { + auctions, + total_count, + } = ev + { + auction_owner_list_result(auctions, total_count, &mut auction); + } +} + +fn on_bidder_list_result(In(ev): In, mut auction: ResMut) { + if let SessionEvent::AuctionBidderListResult { + auctions, + total_count, + } = ev + { + auction_bidder_list_result(auctions, total_count, &mut auction); + } +} + +fn on_bidder_notification(In(ev): In, mut auction: ResMut) { + if let SessionEvent::AuctionBidderNotification(n) = ev { + auction_bidder_notification(&n, &mut auction); + } +} + +fn on_owner_notification(In(ev): In, mut auction: ResMut) { + if let SessionEvent::AuctionOwnerNotification(n) = ev { + auction_owner_notification(&n, &mut auction); + } +} + +fn on_removed_notification(In(ev): In, mut auction: ResMut) { + if let SessionEvent::AuctionRemovedNotification { item_entry, .. } = ev { + auction_removed_notification(item_entry, &mut auction); + } +} + +/// An open auction house dies with the socket (decision 1511): every auction command +/// re-validates the auctioneer server-side, so a session that survived a reconnect would be a +/// window whose every button silently failed. A listener on the session end, which the drain's +/// dispatch match still owns ([`crate::net::handlers::BROADCAST`]). +fn on_session_end(In(_): In, mut auction: ResMut) { + auction.clear_session(); +} /// `MSG_AUCTION_HELLO`'s reply — **this**, not our send, is what opens the window (wow-re: the /// window's opener runs inside the hello handler). The house id keys the deposit rate. -pub(super) fn auction_hello(auctioneer: u64, house_id: u32, auction: &mut AuctionOpen) { +fn auction_hello(auctioneer: u64, house_id: u32, auction: &mut AuctionOpen) { auction.open(auctioneer, house_id); } /// One of the three list results. They share a frame and a record; only the tab differs. -pub(super) fn auction_list( +fn auction_list( which: usize, auctions: Vec, total_count: u32, @@ -34,7 +131,7 @@ pub(super) fn auction_list( auction.set_list(which, auctions, total_count); } -pub(super) fn auction_list_result( +fn auction_list_result( auctions: Vec, total_count: u32, auction: &mut AuctionOpen, @@ -42,7 +139,7 @@ pub(super) fn auction_list_result( auction_list(LIST, auctions, total_count, auction); } -pub(super) fn auction_owner_list_result( +fn auction_owner_list_result( auctions: Vec, total_count: u32, auction: &mut AuctionOpen, @@ -54,7 +151,7 @@ pub(super) fn auction_owner_list_result( /// currently hold the bid on, so **one auction can appear twice in a page** — deduped here by /// auction id, keeping the first occurrence, because a duplicated row would be two rows the player /// can click that address the same auction. -pub(super) fn auction_bidder_list_result( +fn auction_bidder_list_result( auctions: Vec, total_count: u32, auction: &mut AuctionOpen, @@ -73,7 +170,7 @@ pub(super) fn auction_bidder_list_result( /// server writes `auc ? auc->Id : 0`), so it is not a correlation handle on an error; and several /// refusals send **no packet at all** (a bid the player cannot afford, a cancel whose cut they /// cannot pay), which is why nothing in this arc blocks its UI waiting for an ack. -pub(super) fn auction_command_result( +fn auction_command_result( auction_id: u32, action: u32, error: u32, @@ -154,10 +251,7 @@ fn command_error_key(error: u32) -> Option<&'static str> { /// /// **`bid_or_zero == 0` means WON**, not "no bid" — the server overloads the field, and reading it /// the obvious way turns every win into an outbid notice. -pub(super) fn auction_bidder_notification( - notice: &AuctionBidderNotification, - auction: &mut AuctionOpen, -) { +fn auction_bidder_notification(notice: &AuctionBidderNotification, auction: &mut AuctionOpen) { let won = notice.bid_or_zero == 0; auction.messages.push(AuctionMessage::chat_item( if won { @@ -172,10 +266,7 @@ pub(super) fn auction_bidder_notification( /// `SMSG_AUCTION_OWNER_NOTIFICATION` — one of ours sold, or took a bid. An all-zero bidder guid is /// the "sold" signal (the server zeroes it on a sale). -pub(super) fn auction_owner_notification( - notice: &AuctionOwnerNotification, - auction: &mut AuctionOpen, -) { +fn auction_owner_notification(notice: &AuctionOwnerNotification, auction: &mut AuctionOpen) { // **Two stages, and the first one decides whether anything is said at all** (wow-re §11.4). // A NON-zero bidder guid is "somebody bid on your auction": the row updates and the client says // nothing — `[0x4cd25f, 0x4cd3bd)` holds no display call. Only a zeroed guid reaches the message @@ -197,7 +288,7 @@ pub(super) fn auction_owner_notification( /// /// One id, unconditionally (wow-re §11.4: `0x4cd480` has exactly one `call 0x496720` and no branch /// selecting an id). -pub(super) fn auction_removed_notification(item_entry: u32, auction: &mut AuctionOpen) { +fn auction_removed_notification(item_entry: u32, auction: &mut AuctionOpen) { auction.messages.push(AuctionMessage::chat_item( "ERR_AUCTION_REMOVED_S", item_entry, @@ -210,6 +301,42 @@ mod tests { use super::*; use benilla_ui::messages::MsgKind; + /// The registration, end to end: a hello and a session end through the real table. + #[test] + fn the_table_routes_the_hello_and_the_session_end_to_the_house() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .init_resource::(); + register(&mut app); + let through_the_match = |_: &mut World, unclaimed: Vec| { + let kinds: Vec = + unclaimed.iter().map(SessionEventKind::from).collect(); + assert_eq!( + kinds, + vec![SessionEventKind::Disconnected], + "the broadcast reaches the match too" + ); + }; + crate::net::handlers::dispatch( + app.world_mut(), + vec![SessionEvent::AuctionHello { + auctioneer: 0x10, + house_id: 1, + }], + |_, unclaimed| assert!(unclaimed.is_empty()), + ); + assert_eq!(app.world().resource::().auctioneer, Some(0x10)); + crate::net::handlers::dispatch( + app.world_mut(), + vec![SessionEvent::Disconnected { + reason: "socket".into(), + end: benilla_protocol::SessionEnd::Lost, + }], + through_the_match, + ); + assert_eq!(app.world().resource::().auctioneer, None); + } + fn keys(auction: &AuctionOpen) -> Vec<(&'static str, MsgKind, Option)> { auction .messages diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 74a04fa9..f822355d 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -158,13 +158,7 @@ impl Plugin for UiAuraPlugin { .init_resource::() // Feed before the VM dispatch (like the unit feed), so a frame's OnEvent sees the fresh // list; drain the cancels after, once the VM has queued them. - .add_systems( - Update, - feed_auras - .in_set(UnitFeed) - .in_set(AuraEvents) - .before(UiInput), - ) + .add_systems(Update, feed_auras.in_set(UnitFeed).in_set(AuraEvents)) .add_systems(Update, drain_aura_cancels.after(UiInput)) // The ONLY teardown of the aura state, and it hangs off the session edge — never off // the avatar entity's existence, which a worldport interrupts mid-session (0900). diff --git a/crates/benilla-app/src/ui_bank.rs b/crates/benilla-app/src/ui_bank.rs index 36929cb5..42d1c045 100644 --- a/crates/benilla-app/src/ui_bank.rs +++ b/crates/benilla-app/src/ui_bank.rs @@ -25,7 +25,7 @@ use bevy::prelude::*; use benilla_ui::script::{BankState, UiScript}; use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; /// The client-side `BankBagSlotPrices.dbc` table (decision 0604: the purchase ladder is client @@ -95,7 +95,7 @@ impl Plugin for UiBankPlugin { // Range-close before the feed so the clear turns into BANKFRAME_CLOSED the // same frame; push before the input pass; drain after it (the merchant shape). close_npc_session_out_of_range::.before(feed_bank), - feed_bank.before(UiInput), + feed_bank.in_set(UiFeed), drain_bank.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_battlefield.rs b/crates/benilla-app/src/ui_battlefield.rs index 67cb7835..c1dbd71e 100644 --- a/crates/benilla-app/src/ui_battlefield.rs +++ b/crates/benilla-app/src/ui_battlefield.rs @@ -38,7 +38,7 @@ use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands}; use crate::player::Player; use crate::ui_dialog_verbs::BattlefieldQueue; use crate::ui_party::GroupState; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// The leash radius, squared: the CRT initialiser's `fld [0x806574]; fmul` of the `.rdata` f32 /// `5.55555534362793` (§7.1). @@ -365,11 +365,13 @@ impl Plugin for BattlefieldPlugin { app.init_resource::().add_systems( Update, ( - reset_on_world_enter.before(feed_battlefield), + reset_on_world_enter + .in_set(crate::ui_script::UiFeed) + .before(feed_battlefield), feed_battlefield .before(crate::ui_battlefield_score::feed_battlefield_score) .before(crate::ui_dialog_verbs::feed_dialog_verbs) - .before(UiInput), + .in_set(UiFeed), drain_battlefield.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_battlefield_positions.rs b/crates/benilla-app/src/ui_battlefield_positions.rs index ddac2ba1..0b8aa1c1 100644 --- a/crates/benilla-app/src/ui_battlefield_positions.rs +++ b/crates/benilla-app/src/ui_battlefield_positions.rs @@ -35,7 +35,7 @@ use crate::names::NameCache; use crate::net::{ClientCommand, EnteredWorldMessage, GuidIndex, NetCommands, NetEntity, SelfGuid}; use crate::ui_dialog_verbs::BattlefieldQueue; use crate::ui_party::GroupState; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_world_map::{project_on_displayed, WorldMapUiData}; /// The reference's request throttle: `RequestBattlefieldPositions` sends at most once per 5000 ms. @@ -231,7 +231,7 @@ impl Plugin for BattlefieldPositionsPlugin { Update, ( reset_on_world_enter.before(feed_battlefield_positions), - feed_battlefield_positions.before(UiInput), + feed_battlefield_positions.in_set(UiFeed), drain_battlefield_positions.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_battlefield_score.rs b/crates/benilla-app/src/ui_battlefield_score.rs index 3577775b..d058da2d 100644 --- a/crates/benilla-app/src/ui_battlefield_score.rs +++ b/crates/benilla-app/src/ui_battlefield_score.rs @@ -26,7 +26,7 @@ use benilla_ui::script::{BattlefieldScoreRow, BattlefieldScores, BattlefieldStat use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; use crate::ui_dialog_verbs::BattlefieldQueue; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::world_state_ui::WorldStateUiRes; /// `RequestBattlefieldScoreData`'s throttle — `0x4aa170`: `now + 0x1388`. @@ -182,7 +182,7 @@ impl Plugin for BattlefieldScorePlugin { // status-3 message, as in the client. feed_battlefield_score .before(crate::ui_dialog_verbs::feed_dialog_verbs) - .before(UiInput), + .in_set(UiFeed), drain_battlefield_score.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_binder.rs b/crates/benilla-app/src/ui_binder.rs index 0604f976..22a0feec 100644 --- a/crates/benilla-app/src/ui_binder.rs +++ b/crates/benilla-app/src/ui_binder.rs @@ -45,7 +45,7 @@ use bevy::prelude::*; use crate::area::AreaTableRes; use crate::net::{ClientCommand, NetCommands}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; /// The innkeeper's pending bind question. Written by the net drain's `BinderConfirm` arm, read by @@ -266,7 +266,7 @@ impl Plugin for UiBinderPlugin { // Range-close before the feed so walking away takes the dialog down the same // frame (the gossip window's ordering, for the same reason). close_npc_session_out_of_range::.before(feed_binder), - feed_binder.before(UiInput), + feed_binder.in_set(UiFeed), drain_binder.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_cast.rs b/crates/benilla-app/src/ui_cast.rs index 44296736..c8b30405 100644 --- a/crates/benilla-app/src/ui_cast.rs +++ b/crates/benilla-app/src/ui_cast.rs @@ -16,7 +16,6 @@ use benilla_ui::script::{ScriptValue, UiScript}; use crate::creature_anim::{CastEvent, CastEventKind, Casting, PlaySeq}; use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfGuid}; use crate::ui_action::Spells; -use crate::ui_script::UiInput; use crate::ui_unit::UnitFeed; /// One edge of our own cast's lifecycle, queued by the net bridge for the cast bar. @@ -785,10 +784,7 @@ impl Plugin for UiCastPlugin { .init_resource::() .add_systems( Update, - (local_self_cancel, feed_cast_bar) - .chain() - .in_set(UnitFeed) - .before(UiInput), + (local_self_cancel, feed_cast_bar).chain().in_set(UnitFeed), ); } } diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index 7b98490a..cb544a0e 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -55,7 +55,7 @@ use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; use crate::pending_item_ops::PendingItemOps; use crate::portrait::PaperDollBooth; use crate::ui_items::{find_equip_slot, item_link}; -use crate::ui_script::{gate, UiInput}; +use crate::ui_script::{gate, UiFeed, UiInput}; /// Equipment slots, 0-based (`EQUIPMENT_SLOT_*`): the inv-slot array index of the main hand, /// off hand, and ranged slots the weapon-skill / offhand / wand resolutions read. @@ -132,9 +132,9 @@ impl Plugin for UiCharPlugin { // fire" rule, across the two feeds. feed_char .before(crate::ui_items::feed::feed_containers) - .before(UiInput), - watch_skill_ups.before(UiInput), - feed_skills.before(UiInput), + .in_set(UiFeed), + watch_skill_ups.in_set(UiFeed), + feed_skills.in_set(UiFeed), drain_skill_abandons.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index 95fe531e..141fd711 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -10,7 +10,7 @@ use bevy::prelude::*; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; #[cfg(test)] mod ace_gate_tests; @@ -75,6 +75,7 @@ pub(crate) struct UiChatPlugin; impl Plugin for UiChatPlugin { fn build(&self, app: &mut App) { + app.add_observer(combat::on_cvar); app.init_resource::() .init_resource::() .init_resource::() @@ -85,9 +86,9 @@ impl Plugin for UiChatPlugin { .init_resource::() .init_resource::() .init_resource::() - // `CombatLogPeriodicSpells`' knob, beside its sibling range set — both are - // `KnobParams` members, so a missing one is not a dormant default but a - // STARTUP PANIC in `cvars::load_config` (which takes them all as `ResMut`). + // `CombatLogPeriodicSpells`' knob, beside its sibling range set — both are what + // `combat::on_cvar` writes, so a missing one is not a dormant default but a + // PANIC on the first write of either row (2303; before that, at startup). // 947ba585f registered it only in the `cvar_app()` test helper, and the client // stopped booting; the unit suites never noticed because each builds its own // world. `scripts/smoke.sh` is the gate that sees this class. @@ -145,7 +146,12 @@ impl Plugin for UiChatPlugin { // The world broadcasts' resolve pass — before the drain that renders what it produces, // so an alarm or a shutdown countdown lands on the frame it decodes like every other // chat source. - .add_systems(Update, broadcast::feed_broadcasts.before(feed::feed_chat)) + .add_systems( + Update, + broadcast::feed_broadcasts + .in_set(crate::ui_script::UiFeed) + .before(feed::feed_chat), + ) // **Never against the boot VM** (B376's second half). `feed_chat` takes the whole // queue with `mem::take` and fires each line as a real `CHAT_MSG_*` — so a drain // against a VM with no ChatFrame does not defer the lines, it DESTROYS them, with no @@ -183,7 +189,7 @@ impl Plugin for UiChatPlugin { .add_systems( Update, feed::feed_chat - .before(UiInput) + .in_set(UiFeed) .after(crate::ui_unit::UnitFeed) .after(crate::ui_guild::GuildFeed) .run_if(crate::ui_script::ingame_ui_up), @@ -194,11 +200,16 @@ impl Plugin for UiChatPlugin { .add_systems(Update, idle::stamp_input.before(UiInput)) // A fresh VM gets the joined-channel mirror re-pushed once (decision 1291) — before // the feed, so the reload frame's first routed line already renders numbered. - .add_systems(Update, channels::seed_channels.before(feed::feed_chat)) + .add_systems( + Update, + channels::seed_channels + .in_set(crate::ui_script::UiFeed) + .before(feed::feed_chat), + ) // RequestTimePlayed() -> CMSG_PLAYED_TIME, and SMSG_PLAYED_TIME -> TIME_PLAYED_MSG. // Beside the chat feed because /played is a chat command and the answer prints there // too; before the input pass for the same reason feed_chat is. - .add_systems(Update, feed::played_time_bridge.before(UiInput)) + .add_systems(Update, feed::played_time_bridge.in_set(UiFeed)) // The input: open on ENTER (after the UI input pass has set UiKeyboardCapture, so we // don't reopen the box that's already eating keys), then drain any submitted line. Both // touch the single NonSend VM, so they chain. In-world only (decision 0193): at the diff --git a/crates/benilla-app/src/ui_chat/away.rs b/crates/benilla-app/src/ui_chat/away.rs index f6a06d69..196f1d32 100644 --- a/crates/benilla-app/src/ui_chat/away.rs +++ b/crates/benilla-app/src/ui_chat/away.rs @@ -283,14 +283,14 @@ pub(crate) fn reconcile_afk_mirror( /// reference tables that happen to overlap, not one shared idea. pub(crate) fn movement_clears_afk( binds: Res, - cvars: Res, + cvars: Res, script: Option>, commands: Res, mut chat: ResMut, mut mirror: ResMut, ) { use crate::bindings::cmd; - if !mirror.is_afk() || cvars.stored("autoClearAFK").is_some_and(|v| v == "0") { + if !mirror.is_afk() || cvars.flag("autoClearAFK") == Some(false) { return; } // The inline-array idiom `player::follow`'s `move_start` uses — its six, plus JUMP. diff --git a/crates/benilla-app/src/ui_chat/combat.rs b/crates/benilla-app/src/ui_chat/combat.rs index 94bcb08d..3342719a 100644 --- a/crates/benilla-app/src/ui_chat/combat.rs +++ b/crates/benilla-app/src/ui_chat/combat.rs @@ -172,6 +172,17 @@ impl CombatLogRanges { self.death } + /// Whether `name` is one of the eight — asked before [`Self::set`] so an observer holding + /// the resource mutably does not flag a change it did not make. + pub(crate) fn is_range_cvar(&self, name: &str) -> bool { + name.eq_ignore_ascii_case(DEATH_LOG_RANGE_CVAR) + || (0..self.class.len()).any(|i| { + UnitClass::from_index(i) + .range_cvar() + .is_some_and(|c| c.eq_ignore_ascii_case(name)) + }) + } + /// Apply one `SetCVar` to the table — `true` if the name was one of the eight. /// /// The class names are walked through [`UnitClass::range_cvar`] so this module keeps exactly @@ -195,6 +206,20 @@ impl CombatLogRanges { } } +/// The combat log rows' change callback (decision 2303): the eight display ranges (yards, the +/// CVar's float field) and the periodic-effects switch. +pub(crate) fn on_cvar( + ev: On, + mut ranges: ResMut, + mut periodic: ResMut, +) { + if ev.is(LOG_PERIODIC_CVAR) { + periodic.0 = ev.flag(); + } else if ranges.is_range_cvar(&ev.name) { + ranges.set(&ev.name, ev.num()); + } +} + /// **`CombatLogPeriodicSpells`** — "Log Periodic Effects", and its blast radius is wider than the /// options row's wording. /// diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 08552380..2348cddd 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -15,7 +15,7 @@ use bevy::prelude::*; mod parse; #[cfg(test)] pub(super) use parse::lua_quoted_string; -pub(super) use parse::{console_command, parse_line, ParsedChat}; +pub(super) use parse::{parse_line, ParsedChat}; use crate::creature_anim::{move_flags, MovementState}; use crate::net::{ClientCommand, NetCommands, SelfPlayer}; @@ -116,7 +116,10 @@ pub(super) struct ChatProbes<'w, 's> { /// [`crate::target`]'s shared resolver so they commit through the same SetSelection path a click /// does. Chat never writes [`Selection`] itself. #[derive(bevy::ecs::system::SystemParam)] -pub(super) struct ChatOut<'w> { +pub(super) struct ChatOut<'w, 's> { + /// The console registry's lane: a `/console` line runs against the world at the next sync + /// point ([`crate::console::execute`]). + console: Commands<'w, 's>, stand: MessageWriter<'w, crate::player::StandStateRequest>, sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>, target: MessageWriter<'w, crate::target::TargetByNameRequest>, @@ -134,10 +137,6 @@ pub(super) struct ChatOut<'w> { /// since a resource reachable twice from one system is a `B0002` panic on the first live frame /// (1903). Nothing else in this system touches `UiErrorKeys`. ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>, - /// `/console detailDoodadAlpha` — the ground-clutter cutout reference. Rides this bundle for - /// the same arity reason as `ui_errors` above, and like it, nothing else in this system - /// touches `ClutterConfig` (a resource reachable twice from one system is a `B0002` panic). - clutter: ResMut<'w, benilla_world::clutter::ClutterConfig>, } // One parameter per concern — the chat drain fans out to every command's consumer. @@ -168,12 +167,11 @@ fn engine_verbs( out.push((String::new(), ParsedChat::Uninvite { name: Some(name) })); } for line in script.take_console_lines() { - // `ConsoleExec` already wrote the CVar lines to the store; what reaches here is a - // console COMMAND — a name the engine's own command table owns rather than - // `CVar::Register`'s, which is why `detailDoodadAlpha` lives here and not in the CVar - // store (wow-re: registrar `0x63f9e0`, so it never persists — 2012). - let parsed = console_command(&line); - out.push((line, parsed)); + // `ConsoleExec` already wrote the valued CVar lines to the store; what reaches here is + // a console COMMAND — a name the engine's own command table owns rather than + // `CVar::Register`'s (`detailDoodadAlpha`: registrar `0x63f9e0`, so it never persists — + // 2012) — or a bare CVar name for the registry to print (2303). + out.push((line.clone(), ParsedChat::Console { line })); } for cmd in script.take_channel_commands() { out.push((String::new(), ParsedChat::Channel(cmd))); @@ -946,46 +944,26 @@ pub(super) fn drain_chat_input( ParsedChat::Quit => { script.queue_session_request(benilla_ui::script::SessionRequest::Quit) } - // `/reload` and `/console reloadUI` — the same deferred rebuild `ReloadUI()` queues - // (decision 1291), through the same session seam as the exit verbs above. + // `/reload` — the same deferred rebuild `ReloadUI()` queues (decision 1291), through + // the same session seam as the exit verbs above. ParsedChat::ReloadUi => { script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi) } - // `/console detailDoodadAlpha [0..255]` — the reference's own console command - // (`0x6739a0`), and the dial that decides where ground clutter first appears: the - // detail-doodad draw alpha-tests `texel.a x distance_ramp` against it, so 128 (the - // default) hides everything past ~61 yd of the 70 yd fade horizon and a lower value - // walks that onset out toward the horizon. Non-persistent, exactly as the reference's - // command table is. - ParsedChat::DetailDoodadAlpha { value } => { - let text = match value { - Some(v) => { - chat_out.clutter.alpha_ref = f32::from(v) / 255.0; - format!("detailDoodadAlpha set to {v}") + // A `/console` line for the command registry (decision 2303). Deferred to the world + // because a command's body is `fn(&mut World, &str)` — the reference's handlers run + // against the whole client too — and its lines land in the chat frame as system + // text, the seam `/console` output has always used. + ParsedChat::Console { line } => { + chat_out.console.queue(move |world: &mut World| { + let lines = crate::console::execute(world, &line); + let mut log = world.resource_mut::(); + for text in lines { + log.push_event(super::event::ChatEvent::text_only( + super::event::ChatEventKind::System, + text, + )); } - None => format!( - "detailDoodadAlpha is {} (usage: /console detailDoodadAlpha 0-255)", - (chat_out.clutter.alpha_ref * 255.0).round() as u32 - ), - }; - chat_log.push_event(super::event::ChatEvent::text_only( - super::event::ChatEventKind::System, - text, - )); - } - ParsedChat::ConsoleUnknown { cmd } => { - const IMPLEMENTED: &str = "reloadUI, detailDoodadAlpha, and ` `"; - let text = if cmd.is_empty() { - format!("console: no command given (this client implements: {IMPLEMENTED})") - } else { - format!( - "console: '{cmd}' is not implemented (this client implements: {IMPLEMENTED})" - ) - }; - chat_log.push_event(super::event::ChatEvent::text_only( - super::event::ChatEventKind::System, - text, - )); + }); } // The reference's own handler body, run in the VM (the 0668 posture) — `/trade`, // `/inspect`, the loot-method trio, and `/script`'s raw chunk. @@ -1571,8 +1549,8 @@ fn is_dnd(self_q: &Query<&crate::net::ObjectStore, With> /// `autoClearAFK` — registered default `"1"` (`0x5e24d4 push 0x82e748`). Its reader tests /// `[cvar+0x28]` for non-zero (`0x5eb84b`), and with the CVar OFF the clear is a **total** no-op: /// no echo, no mirror write, no packet. -fn auto_clear_afk(cvars: &crate::cvars::CvarPersist) -> bool { - cvars.stored("autoClearAFK").is_none_or(|v| v != "0") +fn auto_clear_afk(cvars: &crate::cvars::Cvars) -> bool { + cvars.flag("autoClearAFK").unwrap_or(true) } /// Turn an addon's `SendChatMessage` calls into sends (decision 1199). @@ -1591,7 +1569,7 @@ pub(super) fn drain_addon_chat_sends( // The optimistic AFK mirror (`[0xb6e5cc]`) the `/afk` toggle reads and writes — 2088. mut mirror: ResMut, // `autoClearAFK`, whose registered default is `"1"` — the gate on the implicit clear. - cvars: Res, + cvars: Res, // Our own descriptor, for the DND arm's LIVE `PLAYER_FLAGS & 0x4` read (DND has no mirror). self_q: Query<&crate::net::ObjectStore, With>, ) { diff --git a/crates/benilla-app/src/ui_chat/input/parse.rs b/crates/benilla-app/src/ui_chat/input/parse.rs index b07557d6..96a66ea3 100644 --- a/crates/benilla-app/src/ui_chat/input/parse.rs +++ b/crates/benilla-app/src/ui_chat/input/parse.rs @@ -163,23 +163,17 @@ pub(in crate::ui_chat) enum ParsedChat { /// straight out of the shipped `GlobalStrings.lua` (decision 0983). Resolved in the drain, /// which holds the VM, so the text can never go stale against the install. MacroHelp, - /// `/reload`, `/console reloadUI`, or `ReloadUI()` typed through `/script` — tear the UI - /// session down and build a new one without leaving the world (decision 1291). Queued on the - /// session seam like [`ParsedChat::Logout`]; [`crate::ui_script::run_pending_reload`] runs it - /// at the top of the next frame. + /// `/reload`, or `ReloadUI()` typed through `/script` — tear the UI session down and build a + /// new one without leaving the world (decision 1291). Queued on the session seam like + /// [`ParsedChat::Logout`]; [`crate::ui_script::run_pending_reload`] runs it at the top of + /// the next frame. (`/console reloadUI` reaches the same request through the command + /// registry's `reloadUI`.) ReloadUi, - /// `/console detailDoodadAlpha [0..255]` — the **ground-clutter cutout reference**, a real - /// console command in the reference (`0x6739a0`; registrar `0x63f9e0`, a command table and not - /// `CVar::Register`, so it never persists — 1804 does not apply to it). It decides where grass - /// first appears: the detail-doodad draw alpha-tests `texel.a x distance_ramp` against this, so - /// at the default 128 nothing survives past ~61 yd of the 70 yd horizon, and lowering it walks - /// that onset out toward the horizon. Bare = report the current value (the reference reads an - /// uninitialised stack slot there; a readout is the useful reading of "no argument"). - DetailDoodadAlpha { value: Option }, - /// A `/console` command this client does not implement — the engine console is otherwise not - /// here. Answered with a system line naming what was asked, because a claimed alias that - /// silently drops its argument reads as a hang. - ConsoleUnknown { cmd: String }, + /// A `/console` line the engine's CVar store did not consume — a command name, or a bare + /// CVar name to print — for the command registry ([`crate::console::execute`], decision + /// 2303): the reference's `ConsoleCommand` table, which every subsystem registers into and + /// which answers an unknown line by saying so. + Console { line: String }, /// A slash line matching neither a chat command nor an `EmotesText` name — dropped. Unknown, } @@ -278,27 +272,6 @@ pub(in crate::ui_chat) fn lua_quoted_string(s: &str) -> String { /// The per-command argument grammar. Each arm is the reference handler's own body reduced to what /// it does with `msg` — the aliases that reach it are the table's business, never this function's. -/// Classify one **console command** line — what is left after `ConsoleExec` has written the CVar -/// lines (2008), i.e. a name the engine's own command table owns rather than `CVar::Register`'s. -/// Pure, so the arms are testable without a VM; called from `engine_verbs`'s console drain. -pub(in crate::ui_chat) fn console_command(line: &str) -> ParsedChat { - let mut words = line.split_whitespace(); - match words.next() { - Some(cmd) if cmd.eq_ignore_ascii_case("reloadui") => ParsedChat::ReloadUi, - // The reference rejects an out-of-range value rather than saturating - // (`0x6739b9: cmp eax,0xff; jbe`), so out-of-range and unparseable are the same `None` - // here and the handler says so. A bare name reports the current value. - Some(cmd) if cmd.eq_ignore_ascii_case("detaildoodadalpha") => { - ParsedChat::DetailDoodadAlpha { - value: words.next().and_then(|v| v.parse::().ok()), - } - } - _ => ParsedChat::ConsoleUnknown { - cmd: line.trim().to_string(), - }, - } -} - fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { use SlashIndex as S; match index { diff --git a/crates/benilla-app/src/ui_chat/logging.rs b/crates/benilla-app/src/ui_chat/logging.rs index 81ac72d2..9d1d830f 100644 --- a/crates/benilla-app/src/ui_chat/logging.rs +++ b/crates/benilla-app/src/ui_chat/logging.rs @@ -130,7 +130,11 @@ pub(super) fn sync_chat_logging( pub(super) fn plugin(app: &mut App) { app.add_systems( Update, - sync_chat_logging.in_set(crate::char_select::InWorldGated), + // After the tick: the flags are Lua's (`LoggingChat`/`LoggingCombat`), read the frame + // they move. + sync_chat_logging + .after(crate::ui_script::UiInput) + .in_set(crate::char_select::InWorldGated), ); } diff --git a/crates/benilla-app/src/ui_chat/settings.rs b/crates/benilla-app/src/ui_chat/settings.rs index c306bb2c..b495232f 100644 --- a/crates/benilla-app/src/ui_chat/settings.rs +++ b/crates/benilla-app/src/ui_chat/settings.rs @@ -809,11 +809,16 @@ pub(super) fn plugin(app: &mut App) { // [`restore_chat_looks`], decision 2119. Only the watcher belongs in `Update`. .add_systems( Update, - watch_chat_looks.in_set(crate::char_select::InWorldGated), + // After the tick: every write it watches is Lua's. + watch_chat_looks + .after(crate::ui_script::UiInput) + .in_set(crate::char_select::InWorldGated), ) .add_systems( Update, - save_chat_looks.in_set(crate::char_select::InWorldGated), + save_chat_looks + .after(crate::ui_script::UiInput) + .in_set(crate::char_select::InWorldGated), ); // **The session-end flush is not registered here.** It is called from inside // `ui_script::end_ui_session` ([`fold_dying_vm_chat_cache`]) so that it covers `/reload` — a diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index ed5edf35..873a76ef 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -2540,47 +2540,6 @@ fn level_up_gains_are_matched_by_level_and_a_miss_is_not_an_absence() { ); } -/// `detailDoodadAlpha` — the reference's own console command (`0x6739a0`), and the dial that -/// decides where ground clutter first appears (the detail-doodad draw alpha-tests -/// `texel.a × distance_ramp` against it). It is a **console command**, not a CVar (registrar -/// `0x63f9e0`, not `CVar::Register`), so it arrives through the console drain rather than the -/// CVar store `ConsoleExec` writes — this pins the classifier that splits the two. The three -/// cases the reference distinguishes: in range sets it, out of range is REJECTED rather than -/// saturated (`0x6739b9: cmp eax,0xff; jbe`), and unparseable is that same rejection. -#[test] -fn console_detail_doodad_alpha_parses() { - use super::input::{console_command, ParsedChat}; - assert_eq!( - console_command("detailDoodadAlpha 8"), - ParsedChat::DetailDoodadAlpha { value: Some(8) } - ); - // The console is case-insensitive, like the engine's own table lookup. - assert_eq!( - console_command("DETAILDOODADALPHA 255"), - ParsedChat::DetailDoodadAlpha { value: Some(255) } - ); - assert_eq!( - console_command("detailDoodadAlpha 256"), - ParsedChat::DetailDoodadAlpha { value: None } - ); - assert_eq!( - console_command("detailDoodadAlpha wat"), - ParsedChat::DetailDoodadAlpha { value: None } - ); - // Bare = report the current value; and the sibling command still routes. - assert_eq!( - console_command("detailDoodadAlpha"), - ParsedChat::DetailDoodadAlpha { value: None } - ); - assert_eq!(console_command("reloadUI"), ParsedChat::ReloadUi); - assert_eq!( - console_command("nosuchthing"), - ParsedChat::ConsoleUnknown { - cmd: "nosuchthing".to_string() - } - ); -} - /// **The ding block is printed once, by the reference's own window.** /// /// `benilla.toc` sources `Interface\FrameXML\ChatFrame.xml` off the player's chain, and stock diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index 0a12aa48..7c3de858 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -59,10 +59,7 @@ impl Plugin for UiCraftPlugin { fn build(&self, app: &mut App) { app.init_resource::().add_systems( Update, - ( - feed_craft.in_set(UnitFeed).before(UiInput), - drain_craft.after(UiInput), - ), + (feed_craft.in_set(UnitFeed), drain_craft.after(UiInput)), ); } } diff --git a/crates/benilla-app/src/ui_dialog_verbs.rs b/crates/benilla-app/src/ui_dialog_verbs.rs index 30b6784a..214a64bb 100644 --- a/crates/benilla-app/src/ui_dialog_verbs.rs +++ b/crates/benilla-app/src/ui_dialog_verbs.rs @@ -52,7 +52,7 @@ use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer}; use crate::ui_party::GroupState; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; /// The pet trainer's pending question: the latch (`0xc4d7b0/b4`) and the cost (`0xc4d7b8`). @@ -1100,7 +1100,7 @@ impl Plugin for UiDialogVerbsPlugin { // countdown popup, would be fired at the boot VM in 2214's one-frame window // and lost. Gated, the queue waits; nothing here clears without a VM. feed_dialog_verbs - .before(UiInput) + .in_set(UiFeed) .run_if(crate::ui_script::ingame_ui_up), // **After the frame's pick, which is the reference's own order.** The poll // is `0x4923b0`, called from `CGWorldFrame`'s OnUpdate at `0x4818ca` — and @@ -1133,9 +1133,10 @@ impl Plugin for UiDialogVerbsPlugin { // In-world only: the claim is about the VM, but the query is a world // packet, and the boot VM exists at the glue screen too. meeting_stone_enter_world + .in_set(crate::ui_script::UiFeed) .before(feed_meeting_stone) .in_set(crate::char_select::InWorldGated), - feed_meeting_stone.before(UiInput), + feed_meeting_stone.in_set(UiFeed), // **Before the target chain, not merely after the input pass** — and this one // is a correctness order, not a tidiness one. // diff --git a/crates/benilla-app/src/ui_dressup.rs b/crates/benilla-app/src/ui_dressup.rs index b3cb66e2..796a8303 100644 --- a/crates/benilla-app/src/ui_dressup.rs +++ b/crates/benilla-app/src/ui_dressup.rs @@ -2,7 +2,7 @@ //! window's `DressUpModel` widget verbs ([`DressUpIntent`], queued engine-side) and the booth's //! look ([`DressUpPreview`]). //! -//! Three jobs, each frame, before the VM ticks ([`UiInput`]): +//! Three jobs, each frame, in the feed phase before the VM ticks ([`UiFeed`]): //! //! - **Apply the intents, in order.** `Dress` drops every substitution (the ref's //! `SetUnit("player")` on open and `Dress()` on Reset); `Undress` strips every worn piece but @@ -37,7 +37,7 @@ use crate::entities::equip_slot; use crate::items::Items; use crate::net::{NetCommands, NetEntity, ObjectStore, SelfPlayer}; use crate::portrait::{DressUpLook, DressUpPreview}; -use crate::ui_script::UiInput; +use crate::ui_script::UiFeed; /// The equipment slots a dressing-room look reads off the player — every rendered slot /// (`EQUIPMENT_SLOT_*`), which is exactly the set [`equip_slot`] can map an item into. @@ -229,7 +229,7 @@ pub(crate) struct DressUpUiPlugin; impl Plugin for DressUpUiPlugin { fn build(&self, app: &mut App) { app.init_resource::() - .add_systems(Update, feed_dressup.in_set(UiInput)); + .add_systems(Update, feed_dressup.in_set(UiFeed)); } } diff --git a/crates/benilla-app/src/ui_duel.rs b/crates/benilla-app/src/ui_duel.rs index 4eb83c85..9553505a 100644 --- a/crates/benilla-app/src/ui_duel.rs +++ b/crates/benilla-app/src/ui_duel.rs @@ -49,7 +49,7 @@ use crate::names::NameCache; use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, SelfPlayer}; use crate::target::Selection; use crate::ui_action::Spells; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// `SPELL_EFFECT_DUEL` — the `Effect[0]` value that identifies the duel spell in the player's own /// spellbook (7266 "Duel" on 1.12 data, granted to every race/class at creation). The reference @@ -468,7 +468,7 @@ impl Plugin for UiDuelPlugin { // the VM — so the tick has to run first or every "Duel starting: N" lands a // frame late. tick_countdown.before(feed_duel), - feed_duel.before(UiInput), + feed_duel.in_set(UiFeed), drain_duel.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_follow.rs b/crates/benilla-app/src/ui_follow.rs index 53e1bd7a..62f80780 100644 --- a/crates/benilla-app/src/ui_follow.rs +++ b/crates/benilla-app/src/ui_follow.rs @@ -53,7 +53,7 @@ use bevy::prelude::*; use benilla_ui::script::{FollowRequest as ScriptFollowRequest, ScriptValue, UiScript}; use crate::player::{FollowRequest, FollowState}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// What the VM has last been told, so the two events fire exactly on transitions and never once a /// frame. Mirrors [`FollowState::guid`]; `None` means the VM believes no follow is running. @@ -126,7 +126,7 @@ impl Plugin for UiFollowPlugin { fn build(&self, app: &mut App) { app.init_resource::().add_systems( Update, - (feed_follow.before(UiInput), drain_follow.after(UiInput)), + (feed_follow.in_set(UiFeed), drain_follow.after(UiInput)), ); } } diff --git a/crates/benilla-app/src/ui_gamma.rs b/crates/benilla-app/src/ui_gamma.rs index 84e838a1..34a7839c 100644 --- a/crates/benilla-app/src/ui_gamma.rs +++ b/crates/benilla-app/src/ui_gamma.rs @@ -315,8 +315,18 @@ fn use_gamma_ui_shaders( pub(crate) struct UiGammaPlugin; +/// Brightness's change callback (2182, 2303). The clamp is OURS and the reference has none — +/// the reason it costs one is on [`GAMMA_RANGE`], and nothing a player can reach from the panel +/// meets it. +pub(crate) fn on_cvar(ev: On, mut gamma: ResMut) { + if ev.is(benilla_ui::script::CVAR_GAMMA) { + gamma.0 = ev.num().clamp(*GAMMA_RANGE.start(), *GAMMA_RANGE.end()); + } +} + impl Plugin for UiGammaPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .add_plugins(ExtractComponentPlugin::::default()) .add_systems(Update, stamp_lane_gamma); diff --git a/crates/benilla-app/src/ui_gm_ticket.rs b/crates/benilla-app/src/ui_gm_ticket.rs index f012e553..a7fddcf6 100644 --- a/crates/benilla-app/src/ui_gm_ticket.rs +++ b/crates/benilla-app/src/ui_gm_ticket.rs @@ -69,7 +69,7 @@ use benilla_assets::coords::bevy_to_wow; use crate::net::{ClientCommand, NetCommands}; use crate::player::Player; -use crate::ui_script::{UiInput, VmMemo}; +use crate::ui_script::{UiFeed, UiInput, VmMemo}; /// Spell 7355 "Stuck" — what `Stuck()` casts, and the Help window's "Auto-Unstuck". /// @@ -449,7 +449,7 @@ impl Plugin for UiGmTicketPlugin { .add_systems( Update, ( - feed_gm_ticket.before(UiInput), + feed_gm_ticket.in_set(UiFeed), drain_gm_ticket.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_gossip.rs b/crates/benilla-app/src/ui_gossip.rs index 633da2d1..32c7e830 100644 --- a/crates/benilla-app/src/ui_gossip.rs +++ b/crates/benilla-app/src/ui_gossip.rs @@ -34,7 +34,7 @@ use benilla_ui::script::{GossipMenu, GossipOptionView, GossipQuestRow, ScriptVal use crate::names::NameCache; use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer}; use crate::ui_quest::{row_is_active, row_is_one_click}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; /// The open gossip menu, filled by the net bridge ([`crate::net`]) and read by [`feed_gossip`]. The @@ -201,7 +201,7 @@ impl Plugin for UiGossipPlugin { // push before the input pass so an open/close is on screen the same frame; drain // after it so a click's intent goes out the same frame (mirrors ui_items). close_npc_session_out_of_range::.before(feed_gossip), - feed_gossip.before(UiInput), + feed_gossip.in_set(UiFeed), drain_gossip.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_guild/mod.rs b/crates/benilla-app/src/ui_guild/mod.rs index 72659dc2..cf1e5b56 100644 --- a/crates/benilla-app/src/ui_guild/mod.rs +++ b/crates/benilla-app/src/ui_guild/mod.rs @@ -46,7 +46,7 @@ use bevy::prelude::*; use crate::net::{ClientCommand, NetCommands}; use crate::query_cache::QueryCache; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; mod feed; mod lines; @@ -780,15 +780,24 @@ pub(crate) struct GuildFeed; /// The guild windows' session: the wire mirror, the VM feed, and the outbound intents. pub(crate) struct UiGuildPlugin; +/// Guild Member Alert's change callback (1589, 2303) — conjunct 2 of the sign-on/sign-off +/// line's condition; a flag. +pub(crate) fn on_cvar(ev: On, mut notify: ResMut) { + if ev.is("guildMemberNotify") { + notify.0 = ev.flag(); + } +} + impl Plugin for UiGuildPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); crate::query_cache::register::(app); app.init_resource::() .init_resource::() .add_systems( Update, ( - feed::feed_guild.before(UiInput).in_set(GuildFeed), + feed::feed_guild.in_set(UiFeed).in_set(GuildFeed), feed::drain_guild.after(UiInput), ) // **Never against the boot VM** (1348/1978, and B376's half of it): the feed diff --git a/crates/benilla-app/src/ui_honor.rs b/crates/benilla-app/src/ui_honor.rs index 8f51f0f7..813631ac 100644 --- a/crates/benilla-app/src/ui_honor.rs +++ b/crates/benilla-app/src/ui_honor.rs @@ -56,7 +56,7 @@ use bevy::prelude::*; use benilla_ui::script::{HonorState, InspectHonorData, ScriptValue, UiScript}; use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; -use crate::ui_script::{UiInput, VmMemo}; +use crate::ui_script::{UiFeed, VmMemo}; /// The inspect-honor reply we currently hold, or `None` before one lands. /// @@ -90,7 +90,7 @@ impl Plugin for UiHonorPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() - .add_systems(Update, feed_honor.in_set(UiInput)); + .add_systems(Update, feed_honor.in_set(UiFeed)); } } diff --git a/crates/benilla-app/src/ui_inspect.rs b/crates/benilla-app/src/ui_inspect.rs index da622cd2..2bbf48bc 100644 --- a/crates/benilla-app/src/ui_inspect.rs +++ b/crates/benilla-app/src/ui_inspect.rs @@ -47,7 +47,7 @@ use crate::entities::ItemDisplays; use crate::items::Items; use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore}; use crate::portrait::InspectBooth; -use crate::ui_script::UiInput; +use crate::ui_script::UiFeed; /// Which unit the inspect window is bound to — the ref's `InspectFrame.unit`, mirrored app-side so /// the feed knows whether to resolve anything. The **token** is the identity (not the guid): the @@ -81,7 +81,7 @@ impl Plugin for InspectUiPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() - .add_systems(Update, feed_inspect.in_set(UiInput)); + .add_systems(Update, feed_inspect.in_set(UiFeed)); } } diff --git a/crates/benilla-app/src/ui_instance.rs b/crates/benilla-app/src/ui_instance.rs index 9cd9409c..49cf12ca 100644 --- a/crates/benilla-app/src/ui_instance.rs +++ b/crates/benilla-app/src/ui_instance.rs @@ -113,7 +113,7 @@ use bevy::prelude::*; use crate::net::{ClientCommand, NetCommands}; use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// The window `CanShowResetInstances()` offers the row in, in seconds — `cmp eax, 0x15f90` at /// `0x495ce6` against `time(0) - `, unsigned, so the boundary is inclusive. @@ -590,7 +590,7 @@ impl Plugin for UiInstancePlugin { // a map change and its answer should not be a frame apart. clear_witness_on_logout.before(track_instance_state), track_instance_state.before(feed_instance), - feed_instance.before(UiInput), + feed_instance.in_set(UiFeed), drain_instance.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_item_text.rs b/crates/benilla-app/src/ui_item_text.rs index 09fa648a..bfb0caff 100644 --- a/crates/benilla-app/src/ui_item_text.rs +++ b/crates/benilla-app/src/ui_item_text.rs @@ -38,7 +38,7 @@ use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; use crate::query_cache::QueryCache; use crate::ui_mail::MailOpen; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// One page of a book as the wire gave it (`SMSG_PAGE_TEXT_QUERY_RESPONSE`). pub(crate) struct PageText { @@ -190,7 +190,7 @@ impl Plugin for UiItemTextPlugin { ( // Feed before the input pass so an open paints the same frame; drain after it so // a Close click clears the same frame (the ui_mail ordering). - feed_item_text.before(UiInput), + feed_item_text.in_set(UiFeed), drain_item_text.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_items/mod.rs b/crates/benilla-app/src/ui_items/mod.rs index a59f28bd..1e38fd77 100644 --- a/crates/benilla-app/src/ui_items/mod.rs +++ b/crates/benilla-app/src/ui_items/mod.rs @@ -1174,16 +1174,15 @@ impl Plugin for UiItemsPlugin { // pie waits for the NEXT store change). feed_containers .in_set(UnitFeed) - .before(crate::ui_action::CooldownEvents) - .before(UiInput), + .before(crate::ui_action::CooldownEvents), // The shared item-tooltip store: answer stat asks before the input pass so a // re-hover the very next frame already sees them. - feed_item_stats.in_set(UnitFeed).before(UiInput), - feed_item_sets.in_set(UnitFeed).before(UiInput), + feed_item_stats.in_set(UnitFeed), + feed_item_sets.in_set(UnitFeed), // The roll table, pushed whole once per VM (1547) — before the input pass, so // the first hover of the session already resolves a drop's suffix lines. - feed_random_properties.in_set(UnitFeed).before(UiInput), - feed_player_req.in_set(UnitFeed).before(UiInput), + feed_random_properties.in_set(UnitFeed), + feed_player_req.in_set(UnitFeed), // After the input pass, so a click's UseContainerItem goes out the same frame. drain_container_uses.after(UiInput), // The left-click pick/place/split drain — a queued move → CMSG_SWAP_INV_ITEM / diff --git a/crates/benilla-app/src/ui_layout.rs b/crates/benilla-app/src/ui_layout.rs index e0016fa9..14a98660 100644 --- a/crates/benilla-app/src/ui_layout.rs +++ b/crates/benilla-app/src/ui_layout.rs @@ -337,8 +337,16 @@ impl Plugin for UiLayoutPlugin { // which is the reference's own single write site and the only one a `/reload` reaches. app.init_resource::().add_systems( Update, - (load_layout, watch_layout, save_layout) - .chain() + ( + // The restore is a push the tick should see the frame it lands: the feed phase. + load_layout.in_set(crate::ui_script::UiFeed), + // The watcher and the save read what the drag pump — Lua's, in the tick — did: + // after it. Load precedes watch through the phases, as the old chain had it, + // so the watcher never reads the restore's own move. + (watch_layout, save_layout) + .chain() + .after(crate::ui_script::UiInput), + ) .in_set(crate::char_select::InWorldGated), ); } diff --git a/crates/benilla-app/src/ui_logout.rs b/crates/benilla-app/src/ui_logout.rs index a9eede50..3e863b9f 100644 --- a/crates/benilla-app/src/ui_logout.rs +++ b/crates/benilla-app/src/ui_logout.rs @@ -45,7 +45,7 @@ use benilla_ui::script::{SessionRequest, UiScript}; use bevy::prelude::*; use crate::net::{ClientCommand, LoggedOutMessage, NetCommands, SelfGuid}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// The reference's refusal line, as a **catalog key** rather than a literal — message id `0x180`, /// the sole argument of the `DisplayError` at `0x5aaf26` (decision 1821). VERIFIED, superseding @@ -264,7 +264,7 @@ impl Plugin for UiLogoutPlugin { app.init_resource::().add_systems( Update, ( - feed_logout.before(UiInput), + feed_logout.in_set(UiFeed), drain_logout.after(UiInput), exit_on_logout_complete.after(UiInput), ), diff --git a/crates/benilla-app/src/ui_loot.rs b/crates/benilla-app/src/ui_loot.rs index 55fb8c80..a7035baf 100644 --- a/crates/benilla-app/src/ui_loot.rs +++ b/crates/benilla-app/src/ui_loot.rs @@ -34,7 +34,7 @@ use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; use crate::ui_items::KEYRING_CONTAINER; use crate::ui_party::{GroupState, GROUPTYPE_RAID, GROUP_MEMBER_SUBGROUP}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// The coin-pile row icons (direct `Interface\Icons` paths — `SetTexture` takes them as-is, no DBC), /// **six of them, one per decade of copper**, all VERIFIED to extract from `interface.MPQ`. @@ -470,7 +470,7 @@ impl LootState { /// `show_loot_spam` is 1.12's own `showLootSpam` — the *Detailed Loot Information* checkbox, whose /// subject is **group loot rolls**, not loot messages generally (decision 1589, the Chat page). /// It rides here rather than on [`crate::ui_loot_roll`] because it is one loot knob among the -/// loot knobs and `cvars::KnobParams` fetches this resource already. VERIFIED at the bytes (wow-re +/// loot knobs and [`on_cvar`] writes both. VERIFIED at the bytes (wow-re /// `system/object-layer/scratch/lootroll-chat-and-lifecycle.md` §4): the CVar is `0xb4e2bc`, /// registered at `0x48fd1c` with default `"1"` and flags 5, and a byte census over the whole /// binary finds exactly four references — one writer and three readers, all three inside the @@ -618,8 +618,19 @@ pub(crate) struct LootMoveStart(pub(crate) bool); pub(crate) struct UiLootPlugin; +/// The loot rows' change callback (decision 2303): two flags. +pub(crate) fn on_cvar(ev: On, mut loot: ResMut) { + match ev.key().as_str() { + "autolootdefault" => loot.auto_loot = ev.flag(), + // The loot-roll detail switch (1589) — a flag over the roll-line composer's two shapes. + "showlootspam" => loot.show_loot_spam = ev.flag(), + _ => {} + } +} + impl Plugin for UiLootPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .init_resource::() .init_resource::() @@ -630,7 +641,7 @@ impl Plugin for UiLootPlugin { ( // Push before the input pass so an open/close is on screen the same frame; drain // after it so a click's intent goes out the same frame (mirrors ui_merchant). - feed_loot.before(UiInput), + feed_loot.in_set(UiFeed), drain_loot.after(UiInput), // Predicate B, per frame, after the net drain that arms the latch. The anim // driver then orders itself after THIS (`crate::creature_anim`), closing the diff --git a/crates/benilla-app/src/ui_loot_roll.rs b/crates/benilla-app/src/ui_loot_roll.rs index d8b8557d..7d6d4f79 100644 --- a/crates/benilla-app/src/ui_loot_roll.rs +++ b/crates/benilla-app/src/ui_loot_roll.rs @@ -94,7 +94,7 @@ use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands, SelfGuid}; use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// Give up re-checking a pending announcement's names after this many frames — the same budget and /// reasoning as [`crate::ui_loot`]'s receive lines (a negative-cached entry never resolves). @@ -299,7 +299,7 @@ impl Plugin for UiLootRollPlugin { // Same ordering rule as the loot window (ui_loot): push before the input pass so a // freshly opened roll is on screen the same frame, drain after it so a Need/Greed/ // Pass click goes out the same frame. - feed_loot_rolls.before(UiInput), + feed_loot_rolls.in_set(UiFeed), drain_loot_rolls.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_macro/mod.rs b/crates/benilla-app/src/ui_macro/mod.rs index 3d23ea60..43fa68c6 100644 --- a/crates/benilla-app/src/ui_macro/mod.rs +++ b/crates/benilla-app/src/ui_macro/mod.rs @@ -71,19 +71,24 @@ impl Plugin for UiMacroPlugin { // old answer to the ordering half alone — after `AssetSet::Open` this ran // before the VM existed and silently pushed no icon list at all — and a // session-keyed claim answers both halves at once. - load_icon_catalog, + load_icon_catalog.in_set(crate::ui_script::UiFeed), // Before the action feeds read it (they run in `UnitFeed`), so a macro edited // this frame reports its new spell's cooldown the same frame. - rebind_macro_spells.before(crate::ui_unit::UnitFeed), + rebind_macro_spells + .in_set(crate::ui_script::UiFeed) + .before(crate::ui_unit::UnitFeed), // Load runs in-world only: the per-character file needs the character, and the // roster only names it once a login is live. It self-gates on the identity, so // a re-entry with a different character reloads and a re-entry with the same // one is a no-op. - load_macros.in_set(InWorldGated), + load_macros + .in_set(crate::ui_script::UiFeed) + .in_set(InWorldGated), // The save edge is checked every frame, in or out of world: the macro window is // `whileDead = 1` and reachable from the game menu, and a `/logout` must not - // strand an unsaved edit. - save_dirty_macros.after(load_macros), + // strand an unsaved edit. After the tick that dirtied it — which also puts it + // after the load, in the feed phase. + save_dirty_macros.after(crate::ui_script::UiInput), ), ); } diff --git a/crates/benilla-app/src/ui_mail/mod.rs b/crates/benilla-app/src/ui_mail/mod.rs index ac2ebcc2..45168c83 100644 --- a/crates/benilla-app/src/ui_mail/mod.rs +++ b/crates/benilla-app/src/ui_mail/mod.rs @@ -55,7 +55,7 @@ use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer}; use crate::query_cache::QueryCache; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; mod pending; @@ -217,7 +217,7 @@ impl Plugin for UiMailPlugin { // out the same frame (the ui_merchant ordering exactly). After the UnitFeed set // so the SetInboxItem tooltip reads a landed item-template store. close_npc_session_out_of_range::.before(feed_mail), - feed_mail.after(crate::ui_unit::UnitFeed).before(UiInput), + feed_mail.after(crate::ui_unit::UnitFeed).in_set(UiFeed), drain_mail.after(UiInput), // The world-enter one-shot (decision 0548 §7: "once at UI/login load") — its // own system, ordering-independent of the feed/drain pair above. diff --git a/crates/benilla-app/src/ui_merchant.rs b/crates/benilla-app/src/ui_merchant.rs index eff87584..f7e7ea91 100644 --- a/crates/benilla-app/src/ui_merchant.rs +++ b/crates/benilla-app/src/ui_merchant.rs @@ -30,7 +30,7 @@ use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer}; use crate::ui_items::{item_link, slot_guid, wire_pos}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; use benilla_protocol::messages::BAG_PLAYER_INVENTORY; @@ -145,9 +145,7 @@ impl Plugin for UiMerchantPlugin { // unordered, the first paint races feed_item_stats/feed_player_req and the // usable reds drop until the next content repaint. close_npc_session_out_of_range::.before(feed_merchant), - feed_merchant - .after(crate::ui_unit::UnitFeed) - .before(UiInput), + feed_merchant.after(crate::ui_unit::UnitFeed).in_set(UiFeed), drain_merchant.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_mirror.rs b/crates/benilla-app/src/ui_mirror.rs index 591528f7..ea3daed0 100644 --- a/crates/benilla-app/src/ui_mirror.rs +++ b/crates/benilla-app/src/ui_mirror.rs @@ -22,7 +22,6 @@ use bevy::prelude::*; use benilla_protocol::messages::{MirrorTimerKind, MirrorTimerStart}; use crate::ui_action::Spells; -use crate::ui_script::UiInput; use crate::ui_unit::UnitFeed; /// One mirror-timer edge off the wire, queued by the net bridge for the bars. @@ -197,7 +196,7 @@ pub(crate) struct UiMirrorPlugin; impl Plugin for UiMirrorPlugin { fn build(&self, app: &mut App) { app.init_resource::() - .add_systems(Update, feed_mirror_timers.in_set(UnitFeed).before(UiInput)); + .add_systems(Update, feed_mirror_timers.in_set(UnitFeed)); } } diff --git a/crates/benilla-app/src/ui_models/mod.rs b/crates/benilla-app/src/ui_models/mod.rs index 6cd058c2..50d7b3f9 100644 --- a/crates/benilla-app/src/ui_models/mod.rs +++ b/crates/benilla-app/src/ui_models/mod.rs @@ -653,7 +653,7 @@ impl Plugin for UiModelsPlugin { // published (the UI pass's `paint_script`) — see [`forget_dead_vm_tiles`]. .add_systems( Update, - forget_dead_vm_tiles.before(crate::ui_script::UiInput), + forget_dead_vm_tiles.in_set(crate::ui_script::UiFeed), ) // After the extract published this frame's requests, and before the pose/palette // passes read the roots' transforms (they run in PostUpdate). diff --git a/crates/benilla-app/src/ui_net.rs b/crates/benilla-app/src/ui_net.rs index 2ef4260e..f5d6720f 100644 --- a/crates/benilla-app/src/ui_net.rs +++ b/crates/benilla-app/src/ui_net.rs @@ -21,13 +21,13 @@ use benilla_ui::script::UiScript; use benilla_assets::LockRecover; use crate::net::PingShared; -use crate::ui_script::UiInput; +use crate::ui_script::UiFeed; pub(crate) struct UiNetPlugin; impl Plugin for UiNetPlugin { fn build(&self, app: &mut App) { - app.add_systems(Update, feed_net_stats.before(UiInput)); + app.add_systems(Update, feed_net_stats.in_set(UiFeed)); } } diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index bdf3cf37..bea9d671 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -32,7 +32,7 @@ use benilla_protocol::messages::{ }; use bevy::prelude::*; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; mod feed; pub(crate) use feed::{ @@ -47,7 +47,7 @@ impl Plugin for UiPartyPlugin { app.init_resource::().add_systems( Update, ( - feed::feed_party.before(UiInput), + feed::feed_party.in_set(UiFeed), feed::drain_party.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs index bc2605a2..8be067d2 100644 --- a/crates/benilla-app/src/ui_pet/mod.rs +++ b/crates/benilla-app/src/ui_pet/mod.rs @@ -155,13 +155,11 @@ impl Plugin for UiPetPlugin { // edges against last frame's answer and the Pet tab never came up. feed_pet_bar .in_set(UnitFeed) - .after(crate::ui_pet_stats::PetSnapshot) - .before(UiInput), + .after(crate::ui_pet_stats::PetSnapshot), feed_pet_unit .in_set(UnitFeed) - .after(crate::ui_pet_stats::PetSnapshot) - .before(UiInput), - feed_pet_menu.in_set(UnitFeed).before(UiInput), + .after(crate::ui_pet_stats::PetSnapshot), + feed_pet_menu.in_set(UnitFeed), drain_pet_actions.after(UiInput), drain_pet_menu.after(UiInput), ), diff --git a/crates/benilla-app/src/ui_pet_book.rs b/crates/benilla-app/src/ui_pet_book.rs index 132c700d..fd718e8d 100644 --- a/crates/benilla-app/src/ui_pet_book.rs +++ b/crates/benilla-app/src/ui_pet_book.rs @@ -72,8 +72,7 @@ impl Plugin for UiPetBookPlugin { // tab flipped this frame already reads a populated book. feed_pet_book .in_set(UnitFeed) - .before(crate::ui_action::CooldownEvents) - .before(UiInput), + .before(crate::ui_action::CooldownEvents), // After the input pass, and — like `ui_pet`'s own drain — writing back into // `PetBar`, whose next feed carries the mirrored autocast bit. drain_pet_book.after(UiInput), diff --git a/crates/benilla-app/src/ui_pet_doll.rs b/crates/benilla-app/src/ui_pet_doll.rs index 9634563b..73a1f404 100644 --- a/crates/benilla-app/src/ui_pet_doll.rs +++ b/crates/benilla-app/src/ui_pet_doll.rs @@ -1,7 +1,7 @@ //! The app-side **pet paper-doll feed** (decision 1057) — `PetPaperDollFrame`'s data, the //! [`crate::ui_char`] pattern pointed at the pet. //! -//! Two jobs, each frame, before the VM ticks ([`UiInput`]): +//! Two jobs, each frame, in the feed phase before the VM ticks ([`crate::ui_script::UiFeed`]): //! //! - **The pet's [`UnitCombatStats`]**, built through [`crate::ui_char::unit_combat_stats`] — the //! descriptor-only core, which is all a creature has. It is the *same* snapshot type and the @@ -35,7 +35,6 @@ use benilla_ui::script::{UiScript, UnitCombatStats}; use crate::portrait::PetDollBooth; use crate::ui_char::{fire_stat_transitions, unit_combat_stats}; use crate::ui_pet::{PetBar, PetUnit}; -use crate::ui_script::UiInput; use crate::ui_unit::UnitFeed; pub(crate) struct UiPetDollPlugin; @@ -44,7 +43,7 @@ impl Plugin for UiPetDollPlugin { fn build(&self, app: &mut App) { // Rides the unit feed beside the pet bar's and the stat block's, and before the VM ticks — // the whole page repaints out of the one pass that pushes the pet's health. - app.add_systems(Update, feed_pet_doll.in_set(UnitFeed).before(UiInput)); + app.add_systems(Update, feed_pet_doll.in_set(UnitFeed)); } } diff --git a/crates/benilla-app/src/ui_pet_stats.rs b/crates/benilla-app/src/ui_pet_stats.rs index fe4b4e25..553a9b79 100644 --- a/crates/benilla-app/src/ui_pet_stats.rs +++ b/crates/benilla-app/src/ui_pet_stats.rs @@ -34,7 +34,6 @@ use benilla_ui::script::{PetStats, ScriptValue, UiScript}; use crate::names::NameCache; use crate::net::{NetCommands, ObjectStore}; use crate::ui_pet::{PetBar, PetUnit}; -use crate::ui_script::UiInput; use crate::ui_unit::UnitFeed; /// `UNIT_FIELD_BYTES_0` byte 1 == 3 — **Hunter**, the class the four stat bindings gate on @@ -93,13 +92,7 @@ impl Plugin for UiPetStatsPlugin { fn build(&self, app: &mut App) { // Rides the unit feed beside the pet bar's own, and before the VM ticks — the pet frame // repaints out of the same pass that pushes its health. - app.add_systems( - Update, - feed_pet_stats - .in_set(UnitFeed) - .in_set(PetSnapshot) - .before(UiInput), - ); + app.add_systems(Update, feed_pet_stats.in_set(UnitFeed).in_set(PetSnapshot)); } } diff --git a/crates/benilla-app/src/ui_petition/mod.rs b/crates/benilla-app/src/ui_petition/mod.rs index c67f0a60..e458dca1 100644 --- a/crates/benilla-app/src/ui_petition/mod.rs +++ b/crates/benilla-app/src/ui_petition/mod.rs @@ -96,7 +96,7 @@ use bevy::prelude::*; use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; use crate::query_cache::QueryCache; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::NpcSession; mod feed; @@ -424,7 +424,7 @@ impl Plugin for UiPetitionPlugin { // frame — every other NPC window's ordering. crate::ui_session::close_npc_session_out_of_range:: .before(feed::feed_petition), - feed::feed_petition.before(UiInput), + feed::feed_petition.in_set(UiFeed), feed::drain_petition.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest.rs index fcffa9d1..7cfa6928 100644 --- a/crates/benilla-app/src/ui_quest.rs +++ b/crates/benilla-app/src/ui_quest.rs @@ -30,7 +30,7 @@ use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer}; use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, Spells, UiError}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; /// The open questgiver view — exactly the wire packet that opened the current panel. The feed turns @@ -264,7 +264,7 @@ impl Plugin for UiQuestPlugin { // drain after it so a click's intent goes out the same frame (mirrors // ui_gossip/merchant). close_npc_session_out_of_range::.before(feed_quest), - feed_quest.before(UiInput), + feed_quest.in_set(UiFeed), drain_quest.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs index b2e04a96..dee3e84d 100644 --- a/crates/benilla-app/src/ui_quest_log.rs +++ b/crates/benilla-app/src/ui_quest_log.rs @@ -56,7 +56,7 @@ use crate::names::NameCache; use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer}; use crate::query_cache::QueryCache; use crate::ui_action::Spells; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_unit::UnitFeed; /// The top bit `QuestObjective::creature_or_go` carries for a gameobject objective — @@ -143,10 +143,10 @@ impl Plugin for UiQuestLogPlugin { .add_systems( Update, ( - feed_quest_log.in_set(UnitFeed).before(UiInput), + feed_quest_log.in_set(UnitFeed), // Before the script tick, so a QuestTimerFrame OnUpdate this frame reads this // frame's clock (the `minimap::feed_game_time` shape). - feed_server_clock.before(UiInput), + feed_server_clock.in_set(UiFeed), drain_quest_log_abandons.after(UiInput), drain_quest_log_pushes.after(UiInput), drain_quest_log_collapses.after(UiInput), diff --git a/crates/benilla-app/src/ui_quest_share.rs b/crates/benilla-app/src/ui_quest_share.rs index b89a374d..ee9fc878 100644 --- a/crates/benilla-app/src/ui_quest_share.rs +++ b/crates/benilla-app/src/ui_quest_share.rs @@ -48,7 +48,7 @@ use bevy::prelude::*; use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// How many frames a queued line waits for its `%s` before it is dropped — [`crate::ui_loot`]'s /// `RECEIVE_MAX_TRIES`, and the same reasoning: a name query is one round trip, so any real answer @@ -144,7 +144,7 @@ impl Plugin for QuestSharePlugin { app.init_resource::().add_systems( Update, ( - feed_quest_share.before(UiInput), + feed_quest_share.in_set(UiFeed), drain_quest_share.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_reputation.rs b/crates/benilla-app/src/ui_reputation.rs index 2bc23dbe..18b8d436 100644 --- a/crates/benilla-app/src/ui_reputation.rs +++ b/crates/benilla-app/src/ui_reputation.rs @@ -39,7 +39,7 @@ use benilla_ui::script::{FactionEntry, ReputationSend, ReputationState, UiScript use crate::net::{ClientCommand, NetCommands, ObjectStore, Reputations, SelfPlayer}; use crate::target::Factions; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// The cumulative standing edges of the eight ranks: `RANK_BOUNDS[r]` is rank `r`'s absolute floor /// and `RANK_BOUNDS[r + 1]` its ceiling. The widths between them are vmangos's `PointsInRank` @@ -57,7 +57,7 @@ impl Plugin for UiReputationPlugin { app.add_systems( Update, ( - feed_reputation.before(UiInput), + feed_reputation.in_set(UiFeed), drain_reputation_sends.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 8217e932..88a5b5af 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -238,7 +238,7 @@ pub(crate) fn arm_entry_ui_load(world: &mut World) { /// phase can write CVars — the character screen's AddOns panel is the live case, a *Load out of /// date AddOns* click — and `sync_cvars` (1291) registers them onto whichever VM is in the world, /// so this is the last moment that table exists. Folded into the persist state, the -/// `set_cvar_saved_base` + `register_cvars` pair in the load below starts the new VM at the +/// `set_cvar_saved_base` + `seed_cvars` pair in the load below starts the new VM at the /// player's value. That is exactly the route `/reload` has always taken through /// [`end_ui_session`]; 2226 makes the first login take it too. /// @@ -483,15 +483,19 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { // gets a turn — so this edge would load a client with no CVar table at all. Nothing had ever // noticed, because until now no interface file read a CVar at load. // - // The two lines are `sync_cvars`'s own first two, in its own order, and the order is - // load-bearing: the saved config goes in FIRST so registration starts each key at the player's - // value rather than the factory one (1291) — reversed, a reload would quietly reset every - // knobless CVar to its default. `sync_cvars` still does its full knob-derived pass on the next - // `Update`; both calls are idempotent, and a re-register never clobbers a live value. - if let Some(persist) = world.get_resource::() { - script.set_cvar_saved_base(persist.saved_base()); + // The two lines are `sync_cvars`'s own seed, in its own order, and the order is + // load-bearing: the file's unclaimed entries go in FIRST so an addon's `RegisterCVar` starts + // its key at the player's value rather than the declared one (1291); then the registry's + // whole table at its live values (2303) — the store that outlived the last VM. `sync_cvars` + // still runs its own per-VM seed on the next `Update`; both are idempotent. A world with no + // registry (a bare test app) gets the registered defaults. + match world.get_resource::() { + Some(cvars) => { + script.set_cvar_saved_base(cvars.orphans()); + script.seed_cvars(cvars.vm_seed()); + } + None => script.register_cvars(crate::cvars::registered_pairs()), } - script.register_cvars(crate::cvars::registered_pairs()); // The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file // scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it // (decision 1195). The roster carries the auth realm-list entry this session connected to. @@ -531,14 +535,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { // character select reached here inside the save debounce; the fold carries that click now, by // the route the reload path always used. Absent both (a bare test world) is the registrar // default: check ON. - let version_check = script - .cvar("checkAddonVersion") - .map(|v| v != "0") - .unwrap_or_else(|| { - world - .get_resource::() - .is_none_or(crate::cvars::CvarPersist::addon_version_check) - }); + let version_check = world + .get_resource::() + .is_none_or(crate::cvars::Cvars::addon_version_check); // **…and the rest of the same class** (decision 2241). Each of these was a per-VM claim in // `Update`, which answers *which* VM and not *when inside its life* — and every one of them // backs a Lua getter the load burst below reads: diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 9e8c044f..8e373bf1 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -281,13 +281,31 @@ pub(crate) struct UiPassState { #[derive(Resource, Default)] pub(crate) struct PointerOverUiPanel(pub(crate) bool); +/// The player-UI FEED phase — every push into the VM that this frame's tick must see: the +/// snapshot feeds, the one-shot drains that `fire_event`, the per-VM seeds. Chained +/// `UiFeed` → [`UiInput`] → [`UiPaint`] and ordered after [`WorldStage::Net`] (decision 2304), so +/// a feed reads what this frame's packets did, and a handler its event fires reads what every +/// other feed pushed — the reference's own frame, where packet dispatch precedes every +/// `FrameScript_SignalEvent` and both precede `OnUpdate`. The failure the order closes was found +/// in the unit feed first ([`crate::ui_unit::UnitFeed`]'s doc): unordered, `apply_net_updates` +/// could land between two feeds, and a synchronous handler fired by the later one re-read the +/// earlier one's pre-mutation push. **No run condition on the set** — whether a feed may run +/// before the in-game interface exists is a judgement per feed (2232), and the ones that may not +/// ride `UnitFeed`, a gated sub-phase of this one. Every system holding the VM in `Update` +/// declares its side of the tick — this set, or `.after(UiInput)` — and +/// `game_plugins::schedule_tests` holds it on the built schedule. +#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct UiFeed; + /// The player-UI PAINT pass — the quad walk ([`extract::paint_script`]), ordered after the world's /// camera update so that world-anchored widgets are drawn from this frame's camera (decision 2168). +/// The last of the three UI phases (see [`UiFeed`]). #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct UiPaint; -/// The player-UI input pass — hit-testing + handler firing. Ordered before [`WorldStage::Input`] so -/// the [`PlayerUiHover`] it produces is folded into `PointerOverUi` before `player::control` reads it. +/// The player-UI input pass — the tick, hit-testing + handler firing. Ordered before +/// [`WorldStage::Input`] so the [`PlayerUiHover`] it produces is folded into `PointerOverUi` before +/// `player::control` reads it; the middle of the three UI phases (see [`UiFeed`]). #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct UiInput; @@ -403,8 +421,31 @@ pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 { /// tick → resolve → extract pipeline into [`UiQuads`], plus the input pass ([`feed_ui_input`]). pub(crate) struct UiScriptPlugin; +/// `uiScale`'s change callback (decision 2303): the dial's own `[0.5, 1.5]` clamp. +pub(crate) fn on_cvar(ev: On, mut scale: ResMut) { + if ev.is("uiScale") { + scale.0 = ev.num().clamp(0.5, 1.5); + } +} + +/// `/console reloadUI` — the reference's own console command, and the same deferred rebuild +/// `ReloadUI()` and `/reload` queue (decision 1291): through the session seam, run by +/// [`run_pending_reload`] at the top of the next frame. +fn console_reload_ui(world: &mut World, _args: &str) -> Vec { + match world.get_non_send_resource_mut::() { + Some(mut script) => { + script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi); + Vec::new() + } + None => vec!["reloadUI: no interface to reload".to_string()], + } +} + impl Plugin for UiScriptPlugin { fn build(&self, app: &mut App) { + use crate::console::ConsoleCommandApp; + app.add_observer(on_cvar); + app.console_command("reloadUI", "Reload the interface.", console_reload_ui); // **The quit root of the shutdown tail, on the exit edge** (decision 1528). It was // `.add_systems(Update, shutdown_on_exit)` below, which cannot see the `AppExit` a player // produces — the close button's is written in `PostUpdate` — so quitting from in-world @@ -474,14 +515,26 @@ impl Plugin for UiScriptPlugin { PreUpdate, (run_pending_reload, lifecycle::run_pending_entry_load).chain(), ) - // `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate` + // **The three UI phases, in order** (decision 2304): every push after the net + // drain, then the tick, then the paint. `UiFeed`'s own doc has the why; `UiInput` + // sits before the world's input stage so the hover it produces reaches the pointer + // arbiter in time (its doc). + .configure_sets( + Update, + ( + UiFeed.after(WorldStage::Net), + UiInput.before(WorldStage::Input), + UiPaint, + ) + .chain(), + ) + // `GetFramerate()`'s host half (decision 1195), in the feed phase so an `OnUpdate` // handler reads this frame's number rather than the previous one's. - .add_systems(Update, feed_framerate.before(UiInput)) + .add_systems(Update, feed_framerate.in_set(UiFeed)) // `tick_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they - // chain (also required because both take the single `NonSend` VM). The pair runs before - // `WorldStage::Input` so the hover result reaches the pointer arbiter in time. The input - // pass is in-world only (decision 0193): the character-select glue screen owns the - // pointer + keyboard there (its exit edge resets the latches this pass normally drives). + // chain (also required because both take the single `NonSend` VM). The input pass is + // in-world only (decision 0193): the character-select glue screen owns the pointer + + // keyboard there (its exit edge resets the latches this pass normally drives). .init_resource::() .init_resource::() .add_systems( @@ -495,8 +548,7 @@ impl Plugin for UiScriptPlugin { // The binding dispatch (0997) runs in this same set, after the feed: it // reads the capture gate the feed just wrote, so a key a focused box // consumed this frame never also fires a binding. - .before(crate::bindings::BindingSet) - .before(WorldStage::Input), + .before(crate::bindings::BindingSet), ) // **The paint half, after the camera** (decision 2168): the quad walk runs once the // world's own update has happened, so a widget anchored to the world — the nameplates, @@ -527,8 +579,8 @@ impl Plugin for UiScriptPlugin { app.add_systems( Update, demo_unit_feed + .in_set(UiFeed) .after(UnitFeed) - .before(UiInput) .run_if(capture_ui_active), ); } diff --git a/crates/benilla-app/src/ui_shapeshift.rs b/crates/benilla-app/src/ui_shapeshift.rs index 33cd644e..f84f8049 100644 --- a/crates/benilla-app/src/ui_shapeshift.rs +++ b/crates/benilla-app/src/ui_shapeshift.rs @@ -214,7 +214,6 @@ impl Plugin for UiShapeshiftPlugin { // after the input pass so a stance click goes out the same frame. feed_shapeshift_bar .in_set(UnitFeed) - .before(UiInput) .before(crate::ui_action::CooldownEvents) .before(crate::ui_aura::AuraEvents), drain_shapeshift_casts.after(UiInput), diff --git a/crates/benilla-app/src/ui_social/mod.rs b/crates/benilla-app/src/ui_social/mod.rs index 3c3069fe..307bda54 100644 --- a/crates/benilla-app/src/ui_social/mod.rs +++ b/crates/benilla-app/src/ui_social/mod.rs @@ -42,7 +42,7 @@ use benilla_protocol::messages::{friend_result, friend_status, FriendEntry, FriendStatusUpdate}; use bevy::prelude::*; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; mod feed; mod query; @@ -322,7 +322,7 @@ impl Plugin for UiSocialPlugin { app.init_resource::().add_systems( Update, ( - feed::feed_social.before(UiInput), + feed::feed_social.in_set(UiFeed), feed::drain_social.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_spellbook.rs b/crates/benilla-app/src/ui_spellbook.rs index a5d51f53..83b4031e 100644 --- a/crates/benilla-app/src/ui_spellbook.rs +++ b/crates/benilla-app/src/ui_spellbook.rs @@ -103,8 +103,7 @@ impl Plugin for UiSpellbookPlugin { // book buttons re-read them (that set's own doc). feed_spellbook .in_set(UnitFeed) - .before(crate::ui_action::CooldownEvents) - .before(UiInput), + .before(crate::ui_action::CooldownEvents), drain_spell_casts.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_stable.rs b/crates/benilla-app/src/ui_stable.rs index 1ddf4df7..a2d1adf4 100644 --- a/crates/benilla-app/src/ui_stable.rs +++ b/crates/benilla-app/src/ui_stable.rs @@ -41,7 +41,7 @@ use benilla_ui::script::{StableIntent, StablePetSlot, StableState, UiScript, NUM use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; use crate::ui_pet_stats::{PetFamilyTables, PetStatTables}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; pub(crate) struct UiStablePlugin; @@ -57,7 +57,7 @@ impl Plugin for UiStablePlugin { // frame; push before the input pass so an open/close is on screen the same frame; // drain after it (the trainer/merchant/gossip ordering). close_npc_session_out_of_range::.before(feed_stable), - feed_stable.before(UiInput), + feed_stable.in_set(UiFeed), drain_stable.after(UiInput), // AFTER the VM ticks, unlike every other booth feed: this one reads the // *selection*, which a click on a slot writes during `UiInput` — and diff --git a/crates/benilla-app/src/ui_summon.rs b/crates/benilla-app/src/ui_summon.rs index a1294121..b913a923 100644 --- a/crates/benilla-app/src/ui_summon.rs +++ b/crates/benilla-app/src/ui_summon.rs @@ -75,7 +75,7 @@ use bevy::prelude::*; use crate::area::AreaTableRes; use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; /// The pending summon question — the reference's four-global bank (module doc, pin 1). Written by /// the net drain's `SummonRequest` arm through [`apply::request`], read by [`feed_summon`] (which @@ -301,7 +301,7 @@ impl Plugin for UiSummonPlugin { // Before the feed, so the frame a session ends is already a frame the feed sees no // memory of the last one ([`crate::death`]'s ordering, for the same reason). end_session_summon.before(feed_summon), - feed_summon.before(UiInput), + feed_summon.in_set(UiFeed), drain_summon.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_tabard.rs b/crates/benilla-app/src/ui_tabard.rs index d5ca4047..d3bfaafa 100644 --- a/crates/benilla-app/src/ui_tabard.rs +++ b/crates/benilla-app/src/ui_tabard.rs @@ -35,7 +35,7 @@ use benilla_ui::script::{TabardHost, TabardIntent, UiScript, TABARD_COUNTS, TABA use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer}; use crate::portrait::PaperDollBooth; use crate::ui_guild::GuildState; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; /// The open designer: the vendor it was opened on (`[0xbdcee8]`), and the save-in-flight latch @@ -298,7 +298,7 @@ impl Plugin for TabardUiPlugin { ( reset_on_world_enter.before(feed_tabard), close_npc_session_out_of_range::.before(feed_tabard), - feed_tabard.before(UiInput), + feed_tabard.in_set(UiFeed), drain_tabard.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_talent.rs b/crates/benilla-app/src/ui_talent.rs index 117d76b1..93a0b3af 100644 --- a/crates/benilla-app/src/ui_talent.rs +++ b/crates/benilla-app/src/ui_talent.rs @@ -66,7 +66,7 @@ impl Plugin for UiTalentPlugin { ( // Feed before UiInput (an N-key open this frame sees a populated window); // the learn drain after (a click's wire goes out the same frame). - feed_talents.in_set(UnitFeed).before(UiInput), + feed_talents.in_set(UnitFeed), drain_talent_learns.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_talent_wipe.rs b/crates/benilla-app/src/ui_talent_wipe.rs index fe710487..fd74b447 100644 --- a/crates/benilla-app/src/ui_talent_wipe.rs +++ b/crates/benilla-app/src/ui_talent_wipe.rs @@ -51,7 +51,7 @@ use benilla_ui::script::{ScriptValue, UiScript}; use bevy::prelude::*; use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; /// The pending respec question. Written by the net drain's `TalentWipeConfirm` arm, read by @@ -206,7 +206,7 @@ impl Plugin for UiTalentWipePlugin { // Range-close before the feed so walking away takes the dialog down the same // frame (the binder question's ordering, for the same reason). close_npc_session_out_of_range::.before(feed_talent_wipe), - feed_talent_wipe.before(UiInput), + feed_talent_wipe.in_set(UiFeed), drain_talent_wipe.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_taxi/mod.rs b/crates/benilla-app/src/ui_taxi/mod.rs index a31ba440..71447d52 100644 --- a/crates/benilla-app/src/ui_taxi/mod.rs +++ b/crates/benilla-app/src/ui_taxi/mod.rs @@ -36,7 +36,7 @@ use benilla_ui::script::{TaxiUiState, UiScript}; use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; use crate::player::Player; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; mod routing; @@ -337,7 +337,7 @@ impl Plugin for UiTaxiPlugin { // frame; push before the input pass so an open/close is on screen the same // frame; drain after it (mirrors ui_merchant/ui_trainer). close_npc_session_out_of_range::.before(feed_taxi), - feed_taxi.before(UiInput), + feed_taxi.in_set(UiFeed), drain_taxi.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index a2faaa38..797aab59 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -25,7 +25,6 @@ use crate::target::{ go_is_nearest, ring_reaction, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC, }; use crate::ui_action::{PlayerActions, Spells}; -use crate::ui_script::UiInput; use crate::ui_unit::{enrich_unit, snapshot, UnitFeed}; pub struct UiTooltipPlugin; @@ -35,8 +34,8 @@ impl Plugin for UiTooltipPlugin { app.add_systems( Update, ( - drive_mouseover_tooltip.in_set(UnitFeed).before(UiInput), - feed_spell_tooltips.in_set(UnitFeed).before(UiInput), + drive_mouseover_tooltip.in_set(UnitFeed), + feed_spell_tooltips.in_set(UnitFeed), ), ); } diff --git a/crates/benilla-app/src/ui_trade.rs b/crates/benilla-app/src/ui_trade.rs index 621abf7d..f3948210 100644 --- a/crates/benilla-app/src/ui_trade.rs +++ b/crates/benilla-app/src/ui_trade.rs @@ -57,7 +57,7 @@ use crate::names::NameCache; use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfPlayer}; use crate::target::Selection; use crate::ui_party::GroupState; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::NpcSession; /// One side's offer as the wire delivered it — the seven slots (index 0 = trade slot 1 … index 6 = @@ -492,8 +492,16 @@ impl NpcSession for TradeSession { pub(crate) struct UiTradePlugin; +/// Block Trades' change callback (1764, 2303): a flag. +pub(crate) fn on_cvar(ev: On, mut block: ResMut) { + if ev.is("BlockTrades") { + block.0 = ev.flag(); + } +} + impl Plugin for UiTradePlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .init_resource::() .add_systems( @@ -504,7 +512,7 @@ impl Plugin for UiTradePlugin { // the same frame (the ui_mail ordering exactly). After the UnitFeed set so the // resolved item-template store is landed. No range-guard registration — a // trade's cancel is server-driven (the module doc). - feed_trade.after(crate::ui_unit::UnitFeed).before(UiInput), + feed_trade.after(crate::ui_unit::UnitFeed).in_set(UiFeed), // The incoming request's answer needs no VM at all — it is wire policy over // engine state — so it sits ahead of the feed, and an accepted request's // `partner` is on screen the same frame the window opens. diff --git a/crates/benilla-app/src/ui_tradeskill.rs b/crates/benilla-app/src/ui_tradeskill.rs index d82bffb0..da98230a 100644 --- a/crates/benilla-app/src/ui_tradeskill.rs +++ b/crates/benilla-app/src/ui_tradeskill.rs @@ -96,7 +96,7 @@ impl Plugin for UiTradeSkillPlugin { // the same frame; the feed pushes before the input pass (the trainer's order); // the drain + repeat machine run after it so a Create click casts this frame. open_trade_skill.before(feed_trade_skill), - feed_trade_skill.in_set(UnitFeed).before(UiInput), + feed_trade_skill.in_set(UnitFeed), drain_trade_skill.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_trainer/mod.rs b/crates/benilla-app/src/ui_trainer/mod.rs index 18f85b43..a9240c20 100644 --- a/crates/benilla-app/src/ui_trainer/mod.rs +++ b/crates/benilla-app/src/ui_trainer/mod.rs @@ -38,7 +38,7 @@ use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, NetCommands}; use crate::ui_action::{PlayerActions, Spells}; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; use crate::ui_spellbook::SkillLines; @@ -143,7 +143,7 @@ impl Plugin for UiTrainerPlugin { // push before the input pass so an open/close is on screen the same frame; drain // after it (mirrors ui_merchant/ui_gossip). close_npc_session_out_of_range::.before(feed_trainer), - feed_trainer.before(UiInput), + feed_trainer.in_set(UiFeed), drain_trainer.after(UiInput), ), ); diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 055691f0..7747952c 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -40,13 +40,17 @@ use crate::net::{ use crate::target::{ring_reaction, Factions, Selection}; use crate::ui_script::{gate, UiInput}; -/// The feed pass — runs **after [`benilla_world::schedule::WorldStage::Net`]** (the feeds snapshot state -/// the net apply writes; unordered, `apply_net_updates` could land BETWEEN two feeds, and a -/// synchronous event fired by the later one then re-read the earlier one's pre-mutation push — -/// the spellbook's cooldown pie stayed cold until a manual reopen, reproduced live 2026-07-31) -/// and before [`UiInput`], so the snapshot + events it produces are in place when the VM ticks -/// and dispatches this frame. A named set so the demo override ([`crate::ui_script`]) can order -/// itself after it. Configured in [`UiUnitPlugin`] — the set's home. +/// The unit-feed pass — the GATED sub-phase of [`crate::ui_script::UiFeed`], which carries the +/// order: after [`benilla_world::schedule::WorldStage::Net`] and before [`UiInput`], so the +/// snapshot + events it produces are in place when the VM ticks and dispatches this frame. That +/// order was found here first (the feeds snapshot state the net apply writes; unordered, +/// `apply_net_updates` could land BETWEEN two feeds, and a synchronous event fired by the later +/// one then re-read the earlier one's pre-mutation push — the spellbook's cooldown pie stayed +/// cold until a manual reopen, reproduced live 2026-07-31), and decision 2304 made it every +/// feed's. What stays this set's own is the gate: every member either fires a login one-shot or +/// latches a per-VM memo, so none may run before the in-game interface exists (1348). A named +/// set so the demo override ([`crate::ui_script`]) can order itself after it. Configured in +/// [`UiUnitPlugin`] — the set's home. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct UnitFeed; @@ -253,7 +257,8 @@ impl Plugin for UiUnitPlugin { app.configure_sets( Update, UnitFeed - .after(benilla_world::schedule::WorldStage::Net) // the set's own doc: the why + // A sub-phase of the feed phase, which carries the order (the set's own doc)… + .in_set(crate::ui_script::UiFeed) // …and never before the in-game UI exists (1348). The whole SET, not just // `feed_units`: every feed in it either fires a login one-shot or latches a // per-VM memo, and both are lost forever against the boot VM. The window and @@ -296,17 +301,20 @@ impl Plugin for UiUnitPlugin { fire_combat_text, ) .chain() - .in_set(UnitFeed) - .before(UiInput), + .in_set(UnitFeed), ) .add_systems(Update, drain_pvp_toggles.after(UiInput)) .add_systems(Update, drain_worn_display_toggles.after(UiInput)) .add_systems(Update, drain_action_bar_toggles.after(UiInput)) .add_systems(Update, feed_default_language.in_set(UnitFeed)) .add_systems(Update, feed_known_languages.in_set(UnitFeed)) - // `load_exhaustion_rows` pushes into the VM, so it runs per VM in `Update` (1290); - // `load_default_languages` only builds a Bevy resource and stays a one-shot. - .add_systems(Update, load_exhaustion_rows) + // `load_exhaustion_rows` pushes into the VM, so it runs per VM in `Update` (1290), in + // the feed phase; `load_default_languages` only builds a Bevy resource and stays a + // one-shot. + .add_systems( + Update, + load_exhaustion_rows.in_set(crate::ui_script::UiFeed), + ) .add_systems(PostStartup, (load_default_languages, load_languages)); } } diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index a56fdfd3..5b741977 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -253,16 +253,159 @@ pub(crate) fn present_mode(vsync: bool) -> PresentMode { pub(crate) struct VideoPlugin; +/// **The Video Options block's change callbacks** (decision 2303) — the rows the reference +/// registers from its one video-options registration block (`0x688470`, wow-re +/// `cvar/scratch/graphics-cost-cvar-census.md` §2), landing on the resources they drive. Each +/// arm writes only its own resource, so a `ViewDistance` change is `farclip` moving and nothing +/// else; the clamps are each row's own, stated beside it. +pub(crate) fn on_cvar( + ev: On, + mut cfg: ResMut, + mut view: ResMut, + mut msaa: ResMut, + msaa_formats: Res, + mut tex_filter: ResMut, + mut clutter: ResMut, + mut weather: ResMut, + mut cvars: ResMut, +) { + use benilla_world::view::{FARCLIP_RANGE, MSAA_RANGE}; + let v = ev.num(); + match ev.key().as_str() { + // The one string row here (1627), composed in the reference's own `WxH` spelling. A + // value that is not a size is consumed with a warn and the window keeps its truth. + "gxresolution" => match parse_resolution(&ev.new) { + Some(size) => cfg.windowed = size, + None => warn!("cvar gxResolution: unparseable value '{}' ignored", ev.new), + }, + // Vertical Sync — a flag like every other checkbox. `apply_present_mode` pushes it to + // the window when this resource moves; nothing else reads it. + "gxvsync" => cfg.vsync = ev.flag(), + // Display mode (1627) — the reference's own polarity: `1` is WINDOWED (the row is + // "Windowed Mode"). `apply_window_mode` pushes it to the window when this moves. + "gxwindow" => cfg.display = display_from_flag(v), + "farclip" => view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()), + // The reference REFUSES an out-of-range write here rather than clamping (`0x688d90` + // echoes "NearClip must be in range 0.01 - 0.33" and returns 0). We clamp, which is the + // table's standing posture for every range — the consumer clamps at its own edge. + "nearclip" => view.set_nearclip(v), + // Multisampling (1629) — the reference's own `atoi`-then-clamp `[1, 16]` at `0x63b250`, + // then the DEVICE's ceiling (1643): a count this GPU does not offer is not a setting + // that degrades, it is a wgpu validation error that kills the render thread on frame + // one. Clamping at the write covers every writer there is: the file, a Lua `SetCVar`, + // the dropdown, and the Defaults button. Latched: the camera reads this once, at spawn. + "gxmultisample" => { + let asked = (v as u32).clamp(*MSAA_RANGE.start(), *MSAA_RANGE.end()); + let granted = msaa_formats.clamp(asked); + if granted != asked { + // At `warn`, the same posture as the seed clamp: the player asked for something + // and did not get it, and this is the only place that fact exists. + warn!("cvar gxMultisample: this GPU does not offer {asked}x multisampling — using {granted}x"); + } + msaa.samples = granted; + } + // The filter policy's two halves (1642). Both write a value nothing reads until the + // next launch — the process policy is published once at the end of `CvarLoad` — which + // is benilla's limitation, not a latch: the reference registers both with `flags = 1` + // and applies them live. `anisotropic` takes the reference's own parse-then-clamp + // `[1, 16]` (`0x689110`); `trilinear` is a flag like every other. + "trilinear" => tex_filter.trilinear = ev.flag(), + "anisotropic" => { + tex_filter.aniso = (v as u32).clamp( + *benilla_assets::ANISO_RANGE.start(), + *benilla_assets::ANISO_RANGE.end(), + ) + } + // The panel's 0/1/2 lands as the density multiplier ×1/×2/×3; the clamp is the 1.12 + // slider's own range (an off-grid hand-edit rides between stops, like every slider). + // The SAME knob has a second registered spelling (2151), MIRRORED below — the knob is + // already written, so the sibling row follows without a callback — so `GetCVar` never + // answers two detail levels for one ground cover. + "worlddetail" => { + clutter.density = v.clamp(0.0, 2.0) + 1.0; + cvars.mirror( + benilla_ui::script::CVAR_FRILL_DENSITY, + &clutter.frill_density().to_string(), + ); + } + // …and in the reference's own cells-per-chunk, with the reference's own `[1, 256]` + // clamp rather than the stop's — `ClutterConfig::set_frill_density` carries both, and + // `terrain_stream::rescatter_clutter` re-scatters the loaded tiles off the resulting + // density change exactly as it does for the row above (0992's setter law, which is the + // callback's own chunk rebuild). + "frilldensity" => { + clutter.set_frill_density(v); + cvars.mirror( + benilla_ui::script::CVAR_WORLD_DETAIL, + &(clutter.density - 1.0).to_string(), + ); + } + // Weather Intensity, the panel's 0..3 step 1 (2181). The reference's callback is + // `0x67b870`, a jump table (`0x67b8e8`) mapping 0/1/2/3 onto the quality cells + // {0.1, 0.33, 0.66, 1.0} in `[0x8680ec]` (wow-re `graphics-cost-cvar-census.md` §4). + // What that table does with an off-grid int is NOT carved, so the clamp here is the + // table's own standing posture rather than a fidelity claim — and it costs nothing + // either way, because `WeatherState::density_gain` already `.min(3)`s its own index. + "weatherdensity" => weather.weather_density = v.trunc().clamp(0.0, 3.0) as u8, + _ => {} + } +} + +/// `/console detailDoodadAlpha [0..255]` — the reference's own console command (`0x6739a0`; +/// registrar `0x63f9e0`, a command table and not `CVar::Register`, so it never persists — 1804 +/// does not apply to it). It is the **ground-clutter cutout reference**, the dial that decides +/// where grass first appears: the detail-doodad draw alpha-tests `texel.a x distance_ramp` +/// against it, so at the default 128 nothing survives past ~61 yd of the 70 yd horizon, and +/// lowering it walks that onset out toward the horizon. The reference rejects an out-of-range +/// value rather than saturating (`0x6739b9: cmp eax,0xff; jbe`), so out-of-range and +/// unparseable are the same here: a readout with the usage. Bare = the readout (the reference +/// reads an uninitialised stack slot there; a readout is the useful reading of "no argument"). +fn detail_doodad_alpha(world: &mut World, args: &str) -> Vec { + let Some(mut clutter) = world.get_resource_mut::() + else { + return vec!["detailDoodadAlpha: this run has no ground clutter".to_string()]; + }; + match args + .split_whitespace() + .next() + .and_then(|v| v.parse::().ok()) + { + Some(v) => { + clutter.alpha_ref = f32::from(v) / 255.0; + vec![format!("detailDoodadAlpha set to {v}")] + } + None => vec![format!( + "detailDoodadAlpha is {} (usage: /console detailDoodadAlpha 0-255)", + (clutter.alpha_ref * 255.0).round() as u32 + )], + } +} + impl Plugin for VideoPlugin { fn build(&self, app: &mut App) { + use crate::console::ConsoleCommandApp; + app.add_observer(on_cvar); + app.console_command( + "detailDoodadAlpha", + "The ground-clutter cutout reference, 0-255 (128 = the default).", + detail_doodad_alpha, + ); app.init_resource::() .init_resource::() .add_systems(Startup, (log_display_session, check_window_pinned).chain()) .add_systems( Update, ( - (drain_restart_gx, (apply_present_mode, apply_window_mode)).chain(), - publish_display_modes, + // After the tick, and after the CVar sync: the stock video window's Okay is + // `SetCVar` per changed row and then `RestartGx()`, in one handler, so the + // staged rows have to be in the registry before the commit reads it + // (decision 2304). + (drain_restart_gx, (apply_present_mode, apply_window_mode)) + .chain() + .after(crate::ui_script::UiInput) + .after(crate::cvars::sync_cvars), + // A push the tick may read (`GetScreenResolutions`): the feed phase. + publish_display_modes.in_set(crate::ui_script::UiFeed), ), ); } @@ -277,27 +420,40 @@ impl Plugin for VideoPlugin { #[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)] pub(crate) struct GxRestarts(u32); -/// Move the interface's `RestartGx()` calls into [`GxRestarts`]. +/// Move the interface's `RestartGx()` calls into [`GxRestarts`] — and **commit the latch**. /// -/// benilla applies its video settings live (this module's doc says so for `gxVSync` and the -/// display mode), so what a restart means HERE is "re-assert them against the window now" rather -/// than "tear the device down and rebuild it". The two systems below do the asserting; this one -/// only carries the request across the VM boundary. +/// The `gx*` rows are latched, as the reference registers them (flags `3`, decision 2303): a +/// `SetCVar("gxVSync", 0)` or a `/console gxWindow 1` is staged, `GetCVar` keeps answering the +/// applied value, and nothing moves until `RestartGx()` — which in the reference re-creates the +/// device and calls `CVar::Update 0x63e060` on each row from inside it. Here the commit is +/// [`crate::cvars::Cvars::commit_latched`], its observers run at the sync point before the two +/// appliers below, and what a restart means beyond that is "re-assert the settings against the +/// window now" rather than "tear the device down": wgpu reconfigures the surface on the next +/// present. The two systems below do the asserting; this one carries the request across the VM +/// boundary and fires the commit. fn drain_restart_gx( script: Option>, mut restarts: ResMut, + mut cvars: ResMut, + mut commands: Commands, ) { let Some(mut script) = script else { return; }; let asks = script.take_restart_gx_asks(); if asks == 0 { - // Never touch the resource on a quiet frame: a `ResMut` deref-mut is a change signal, and + // Never touch the resources on a quiet frame: a `ResMut` deref-mut is a change signal, and // both consumers below are gated on the value moving. return; } restarts.0 = restarts.0.wrapping_add(asks); - info!("video: RestartGx — re-asserting the display mode and present mode"); + let committed = cvars.commit_latched(); + if cvars.has_events() { + for event in cvars.take_events() { + commands.trigger(event); + } + } + info!("video: RestartGx — {committed} staged setting(s) committed; re-asserting the display mode and present mode"); } /// **The reference's own three filters on the resolution list** (wow-re @@ -557,26 +713,25 @@ fn display_session() -> String { /// Push the setting to the window **when the setting moves**, and only then. /// -/// The value compare is the `rescatter_clutter` pattern (0992), and it is load-bearing for two -/// separate reasons. `Res::is_changed()` over-fires: the cvar sync builds its `Knobs` bundle by -/// deref-mutting *every* knob resource, so any CVar write anywhere flags this one. And re-asserting -/// the setting every frame would fight the capture probes, which write `present_mode` on the window -/// directly mid-run (`capture::mod`, `probes::live_fps`) to uncap a measurement — their override -/// has to stick. +/// `Res::is_changed()` is the honest signal since 2303: [`on_cvar`] writes this resource only +/// when one of its own rows moved (before the registry, the CVar host's knob bundle deref-mutted +/// every knob on every write, and this carried a value compare to work around it). The gate is +/// load-bearing: re-asserting the setting every frame would fight the capture probes, which +/// write `present_mode` on the window directly mid-run (`capture::mod`, `probes::live_fps`) to +/// uncap a measurement — their override has to stick. /// -/// First sight deliberately does **not** just arm: `load_config` applies the saved value at -/// `Startup`, after the window already exists at its boot mode, so the first run is the one that -/// reconciles them. +/// First sight deliberately does **not** just arm: an inserted resource reads as changed on its +/// first frame, and `load_config` applies the saved value at `Startup`, after the window already +/// exists at its boot mode — so the first run is the one that reconciles them. fn apply_present_mode( cfg: Res, restarts: Res, mut windows: Query<&mut Window, With>, - mut last: Local>, mut last_restart: Local, ) { // A `RestartGx()` re-asserts even when nothing moved — that is what the caller asked for. let forced = std::mem::replace(&mut *last_restart, restarts.0) != restarts.0; - if last.replace(cfg.vsync) == Some(cfg.vsync) && !forced { + if !cfg.is_changed() && !forced { return; } let want = present_mode(cfg.vsync); @@ -606,14 +761,13 @@ fn apply_window_mode( cfg: Res, restarts: Res, mut windows: Query<&mut Window, With>, - mut last: Local>, mut last_restart: Local, ) { // A `RestartGx()` re-asserts even when nothing moved, and that includes re-applying // `gxResolution` to a window already in windowed mode — the one video setting whose value can // have moved without the MODE moving, and therefore the one this verb is most useful for. let forced = std::mem::replace(&mut *last_restart, restarts.0) != restarts.0; - if last.replace(cfg.display) == Some(cfg.display) && !forced { + if !cfg.is_changed() && !forced { return; } let Ok(mut window) = windows.single_mut() else { diff --git a/crates/benilla-app/src/vplates.rs b/crates/benilla-app/src/vplates.rs index 4c85d14e..da3f8e01 100644 --- a/crates/benilla-app/src/vplates.rs +++ b/crates/benilla-app/src/vplates.rs @@ -372,7 +372,7 @@ fn feed_plate_globals( fn toggle_vplates( binds: Res, mut mode: ResMut, - script: Option>, + mut cvars: ResMut, ) { use crate::bindings::cmd; let (was_enemies, was_friends) = (mode.enemies, mode.friends); @@ -396,14 +396,12 @@ fn toggle_vplates( mode.friends = !both; info!("nameplates: all {}", if !both { "ON" } else { "OFF" }); } - if let Some(mut script) = script { - let flag = |b: bool| if b { "1" } else { "0" }; - if mode.enemies != was_enemies { - script.set_cvar_engine(CVAR_ENEMIES, flag(mode.enemies)); - } - if mode.friends != was_friends { - script.set_cvar_engine(CVAR_FRIENDS, flag(mode.friends)); - } + let flag = |b: bool| if b { "1" } else { "0" }; + if mode.enemies != was_enemies { + cvars.set(CVAR_ENEMIES, flag(mode.enemies)); + } + if mode.friends != was_friends { + cvars.set(CVAR_FRIENDS, flag(mode.friends)); } } @@ -884,8 +882,20 @@ pub(crate) struct VPlateSet; /// V-key nameplates: the toggles + the per-frame gate/draw. pub(crate) struct VPlatesPlugin; +/// The two V-plate toggles' change callback (decision 2303) — the bitmask's two bits, flags +/// like every other checkbox. Lowercased here like every arm; the consts carry the registered +/// spelling. +pub(crate) fn on_cvar(ev: On, mut mode: ResMut) { + if ev.is(CVAR_ENEMIES) { + mode.enemies = ev.flag(); + } else if ev.is(CVAR_FRIENDS) { + mode.friends = ev.flag(); + } +} + impl Plugin for VPlatesPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .init_resource::() .init_resource::() @@ -918,7 +928,7 @@ impl Plugin for VPlatesPlugin { // system would rewire all of that. A V press can therefore reach the globals a frame late, // which costs nothing: their only readers are `UpdateNameplates` at the two world-entry // events and an addon that calls it, never a per-frame path. - .add_systems(Update, feed_plate_globals.before(crate::ui_script::UiInput)); + .add_systems(Update, feed_plate_globals.in_set(crate::ui_script::UiFeed)); } } @@ -1005,34 +1015,40 @@ mod tests { #[test] fn the_v_key_mirrors_into_the_cvar_table() { use crate::bindings::{cmd, BindingsState}; + use crate::cvars::Cvars; let mut app = App::new(); - let script = benilla_ui::script::UiScript::new().unwrap(); - script.register_cvars([(CVAR_ENEMIES, "0"), (CVAR_FRIENDS, "0")]); app.add_systems(Update, toggle_vplates) .init_resource::() - .insert_non_send_resource(script) + .init_resource::() .insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES])); app.update(); assert!(app.world().resource::().enemies, "V turns on"); - let mut script = app - .world_mut() - .non_send_resource_mut::(); + let moved = |app: &mut App| { + app.world_mut() + .resource_mut::() + .take_events() + .into_iter() + .map(|e| (e.name, e.new)) + .collect::>() + }; assert_eq!( - script.take_cvar_changes(), + moved(&mut app), vec![(CVAR_ENEMIES.to_string(), "1".to_string())], - "the change queues, so the host dirties the config file" + "the write is an accepted move, so the config dirties and the mirror learns it" + ); + assert_eq!( + app.world().resource::().get(CVAR_FRIENDS), + Some("0"), + "untouched" ); - assert_eq!(script.cvar(CVAR_FRIENDS).as_deref(), Some("0"), "untouched"); - // And back: the same key mirrors the OFF as an engine write too. + // And back: the same key mirrors the OFF as a host write too. app.world_mut() .insert_resource(BindingsState::test_fired(&[cmd::NAMEPLATES])); app.update(); assert!(!app.world().resource::().enemies, "V turns off"); assert_eq!( - app.world_mut() - .non_send_resource_mut::() - .take_cvar_changes(), + moved(&mut app), vec![(CVAR_ENEMIES.to_string(), "0".to_string())] ); @@ -1042,20 +1058,14 @@ mod tests { app.update(); assert!(app.world().resource::().friends); assert_eq!( - app.world_mut() - .non_send_resource_mut::() - .take_cvar_changes(), + moved(&mut app), vec![(CVAR_FRIENDS.to_string(), "1".to_string())] ); // A frame with nothing fired writes nothing at all. app.world_mut().insert_resource(BindingsState::default()); app.update(); - assert!(app - .world_mut() - .non_send_resource_mut::() - .take_cvar_changes() - .is_empty()); + assert!(moved(&mut app).is_empty()); } /// **The FrameXML mirror is owed to every VM** (decision 2132) — the mode's two bits reach diff --git a/crates/benilla-app/src/world_backdrop.rs b/crates/benilla-app/src/world_backdrop.rs index 96ce7f43..c1382b61 100644 --- a/crates/benilla-app/src/world_backdrop.rs +++ b/crates/benilla-app/src/world_backdrop.rs @@ -428,8 +428,20 @@ fn claim_backdrop( /// See the module doc. pub(crate) struct WorldBackdropPlugin; +/// Render scale's change callback (1639, 2303). Clamped at the knob's edge like every other +/// numeric row; the backdrop re-sizes on the next frame and the world camera's target factor +/// follows it in the same pass, which is what keeps the pick rays where they were. +pub(crate) fn on_cvar(ev: On, mut scale: ResMut) { + if ev.is("renderScale") { + scale.0 = ev + .num() + .clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end()); + } +} + impl Plugin for WorldBackdropPlugin { fn build(&self, app: &mut App) { + app.add_observer(on_cvar); app.init_resource::() .add_systems(Startup, setup_backdrop) .add_systems( diff --git a/crates/benilla-protocol/Cargo.toml b/crates/benilla-protocol/Cargo.toml index 9c857205..93046597 100644 --- a/crates/benilla-protocol/Cargo.toml +++ b/crates/benilla-protocol/Cargo.toml @@ -14,6 +14,9 @@ clap.workspace = true benilla-srp = { path = "../benilla-srp" } flate2 = "1.0" sha1.workspace = true +# `SessionEventKind` — the fieldless twin of `SessionEvent`, the key the app's packet-handler +# table dispatches on (decision 2305). A derive, so the two can never drift apart. +strum = { version = "0.26", features = ["derive"] } [dev-dependencies] # The SRP probes re-derive the handshake by hand (to vary what `benilla-srp` fixes) — examples only. diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index e3318d89..96504d2c 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -146,7 +146,14 @@ impl LoginRefusal { /// One decoded event from the world stream. Carries only primitives + the coarse [`EntityKind`] /// classification — no wire types leak to the app, and no running state lives here. -#[derive(Debug, Clone)] +/// +/// [`SessionEventKind`] is its fieldless twin (one variant per variant, derived): the key the +/// app's packet-handler table dispatches on, as the reference's table is keyed by opcode. +#[derive(Debug, Clone, strum::EnumDiscriminants)] +#[strum_discriminants( + name(SessionEventKind), + derive(Hash, PartialOrd, Ord, strum::EnumIter, strum::IntoStaticStr) +)] pub enum SessionEvent { /// A login attempt progressed to `stage` (decision 0539) — IO-thread-emitted, like /// [`Self::CharacterList`], never wire-decoded. @@ -1609,6 +1616,13 @@ pub enum SessionEvent { }, } +impl SessionEventKind { + /// Every kind, in declaration order — for a census that asks "who handles each?". + pub fn all() -> impl Iterator { + ::iter() + } +} + /// The result of polling the reader for the next packet's events. pub enum Poll { /// A packet decoded into these events. `events` is **empty** for an opcode we parse but do not diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index fdb5edc7..2ee97c18 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -15,7 +15,7 @@ pub mod world; pub use auth::AuthReject; pub use events::{ decode, CharAction, EntityKind, LoginRefusal, LoginStage, MoveSpeeds, Poll, SessionEnd, - SessionEvent, + SessionEvent, SessionEventKind, }; pub use messages::field; pub use messages::{ diff --git a/crates/benilla-ui/src/script/chat_misc.rs b/crates/benilla-ui/src/script/chat_misc.rs index 6310b89a..5952d866 100644 --- a/crates/benilla-ui/src/script/chat_misc.rs +++ b/crates/benilla-ui/src/script/chat_misc.rs @@ -138,9 +138,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // ConsoleExec("name value") — the console line `/console` hands over. A registered CVar is - // written the way `SetCVar` writes it (same store, same change queue, no CVAR_UPDATE token); - // anything else is a console command the app owns. + // ConsoleExec("name value") — the console line `/console` hands over. A registered CVar + // WITH a value is written the way `SetCVar` writes it (same store, same change queue, no + // CVAR_UPDATE token), synchronously, so a script reading it back on the next line sees the + // write. Everything else — a command name, or a bare CVar name, which the reference's + // per-CVar console command answers with `CVar "%s" is "%s"` (`0x63dde0`) — is a line for + // the host's command registry (decision 2303). g.set( "ConsoleExec", lua.create_function(|lua, line: Option| { @@ -152,10 +155,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { .map_or((line.as_str(), ""), |(n, v)| (n, v.trim())); let mut model = lua.app_data_mut::().expect("model app_data"); let is_cvar = model.cvars.contains_key(&name.to_ascii_lowercase()); - if is_cvar { - if !value.is_empty() { - super::cvars::write_cvar(&mut model, name, value.to_string(), None); - } + if is_cvar && !value.is_empty() { + super::cvars::write_cvar(&mut model, name, value.to_string(), None); } else { model.console_lines.push(line.clone()); } @@ -251,7 +252,12 @@ mod tests { "0.8", "the CVar store is the same one SetCVar writes, matched case-insensitively" ); - assert_eq!(s.take_console_lines(), vec!["reloadui".to_string()]); + // A bare CVar name is the host's to answer (2303: the reference prints its value from + // the per-CVar console command), so it rides the command lane like `reloadui`. + assert_eq!( + s.take_console_lines(), + vec!["reloadui".to_string(), "uiScale".to_string()] + ); assert_eq!( s.take_cvar_changes(), vec![("uiScale".to_string(), "0.8".to_string())], diff --git a/crates/benilla-ui/src/script/cvars.rs b/crates/benilla-ui/src/script/cvars.rs index 9d7207ce..bc030772 100644 --- a/crates/benilla-ui/src/script/cvars.rs +++ b/crates/benilla-ui/src/script/cvars.rs @@ -36,6 +36,26 @@ pub(crate) struct CvarSlot { pub name: String, pub value: String, pub default: String, + /// The reference's flag bit1 (`rec+0x1c & 0x2`, decision 2303): a write lands in + /// [`Self::pending`] instead of [`Self::value`], so `GetCVar` keeps answering the applied + /// value until the host commits the latch (`CVar::Update 0x63e060` — for the `gx*` rows, + /// inside `RestartGx`). Seeded by the host ([`super::UiScript::seed_cvars`]); a row the VM + /// registers on its own is never latched, exactly like the console `set` command's create + /// path in the reference. + pub latched: bool, + /// The staged value of a latched row — what the host will commit at the boundary, and what + /// it is told about through the change queue the moment it is written. + pub pending: Option, +} + +/// One row of the host's registry, as the VM's mirror is seeded from it (decision 2303): the +/// registered spelling, the **applied** value, the registered default, and the latch flag. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SeededCvar { + pub name: String, + pub value: String, + pub default: String, + pub latched: bool, } /// One device-supported multisample format, as the Video options dropdown shows it. @@ -173,6 +193,8 @@ impl super::UiScript { name: name.to_string(), value, default: default.to_string(), + latched: false, + pending: None, }, ); } @@ -180,14 +202,44 @@ impl super::UiScript { } } - /// Host-side write (loaded config, env override): sets the value WITHOUT queueing a change - /// event — the host is the caller, an echo would re-dirty the file it just read. Unknown - /// names warn once, same posture as the Lua side. + /// Seed the mirror from the host's registry (decision 2303): every row's spelling, applied + /// value, default and latch flag, creating or overwriting the slot. This is what a fresh VM + /// gets at claim time and at the world-entry edge — the host's table is the store that + /// outlives the VM, so the mirror starts wherever it stands, and a re-seed of a live table + /// is the host re-asserting its truth (a staged value is dropped with it: the host holds + /// the pending copy and pushes it back through the outbox if it still stands). + /// + /// `&self` for [`Self::register_cvars`]'s reason — the interface loader seeds from a + /// `&UiScript` before the stock files read their first CVar (decision 2115). + pub fn seed_cvars(&self, rows: impl IntoIterator) { + let mut model = self.model_mut(); + for row in rows { + let key = row.name.to_ascii_lowercase(); + model.cvars.insert( + key, + CvarSlot { + name: row.name, + value: row.value, + default: row.default, + latched: row.latched, + pending: None, + }, + ); + } + } + + /// Host-side write (a loaded value, a committed latch, the host registry's outbox): sets + /// the value WITHOUT queueing a change event — the host is the caller, an echo would + /// re-dirty the file it just read — and clears a staged value, because a host write IS the + /// commit. Unknown names warn once, same posture as the Lua side. pub fn set_cvar_host(&mut self, name: &str, value: &str) { let mut model = self.model_mut(); let key = name.to_ascii_lowercase(); match model.cvars.get_mut(&key) { - Some(slot) => slot.value = value.to_string(), + Some(slot) => { + slot.value = value.to_string(); + slot.pending = None; + } None => warn_unknown(&mut model, name), } } @@ -217,6 +269,14 @@ impl super::UiScript { std::mem::take(&mut self.model_mut().cvar_changes) } + /// Drain the `(name, default)` rows an addon's `RegisterCVar` created since the last call + /// (decision 2303) — the host's cue to give each one a row in its own registry, which is the + /// store that survives this VM. In the reference an addon's registration lands in the same + /// engine-side table as the client's own; this is how ours does. + pub fn take_cvar_registrations(&mut self) -> Vec<(String, String)> { + std::mem::take(&mut self.model_mut().cvar_registrations) + } + /// A native surface's write — [`set_from_engine`]'s public face: sets the value AND queues /// the change like a Lua `SetCVar`, so the host's sync dirties the config file. The glue /// AddOns screen's *Load out of date AddOns* checkbox is the caller (decision 1293): a @@ -297,15 +357,38 @@ impl super::UiScript { /// code, not UI content, so a miss means this build's host doesn't back the var (a bare test VM) — /// and the registered set is welded to the code truths by `crate::cvars`'s own test. pub(super) fn set_from_engine(model: &mut Model, name: &str, value: String) { - let Some(slot) = model.cvars.get_mut(&name.to_ascii_lowercase()) else { - return; - }; - if slot.value == value { - return; + store(model, &name.to_ascii_lowercase(), value); +} + +/// **The one store every write goes through** — Lua's `SetCVar`, `ConsoleExec`, and the engine +/// writes above. Returns the registered spelling when the write moved something (and queued it +/// for the host), `None` for an unknown key or a write that changed nothing. +/// +/// A **latched** slot (decision 2303) takes the write as its staged value and leaves `value` +/// alone, which is the reference's `Set 0x63df50`: `latchedValue` gets the string, `InternalSet` +/// does not run, and `GetCVar` keeps answering what is applied. Writing a latched slot back to +/// its applied value clears the stage instead of staging a no-op. Either way the host hears it +/// through the change queue — it holds the pending copy and commits it at the boundary. +fn store(model: &mut Model, key: &str, value: String) -> Option { + let slot = model.cvars.get_mut(key)?; + if slot.latched { + let staged = (value != slot.value).then_some(value.clone()); + if slot.pending == staged && staged.is_some() { + return None; + } + if staged.is_none() && slot.pending.is_none() { + return None; + } + slot.pending = staged; + } else { + if slot.value == value { + return None; + } + slot.value = value.clone(); } - slot.value = value.clone(); let registered = slot.name.clone(); - model.cvar_changes.push((registered, value)); + model.cvar_changes.push((registered.clone(), value)); + Some(registered) } /// Push the warn-once for an unknown CVar name into the model's warning stream. @@ -319,26 +402,19 @@ pub(super) fn write_cvar( token: Option, ) -> bool { let key = name.to_ascii_lowercase(); - match model.cvars.get_mut(&key) { - Some(slot) => { - if slot.value != value { - slot.value = value.clone(); - let reg_name = slot.name.clone(); - model.cvar_changes.push((reg_name, value.clone())); - if let Some(token) = token { - model.pending_events.push(( - "CVAR_UPDATE".to_string(), - vec![ScriptValue::Str(token), ScriptValue::Str(value)], - )); - } - } - true - } - None => { - warn_unknown(model, name); - false + if !model.cvars.contains_key(&key) { + warn_unknown(model, name); + return false; + } + if store(model, &key, value.clone()).is_some() { + if let Some(token) = token { + model.pending_events.push(( + "CVAR_UPDATE".to_string(), + vec![ScriptValue::Str(token), ScriptValue::Str(value)], + )); } } + true } fn warn_unknown(model: &mut Model, name: &str) { @@ -395,11 +471,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // saved value survives the VM being replaced — the declared value stays the DEFAULT, // so the saver still knows what "moved off default" means for this key. let saved = model.cvars_saved_base.get(&key).cloned(); - model.cvars.entry(key).or_insert(CvarSlot { - name, - value: saved.unwrap_or_else(|| value.clone()), - default: value, - }); + if model.cvars.contains_key(&key) { + return Ok(()); + } + // The host learns the row through its own queue (decision 2303): an addon-declared + // CVar gets a row in the host's registry, which is the store that survives this VM. + model.cvar_registrations.push((name.clone(), value.clone())); + model.cvars.insert( + key, + CvarSlot { + name, + value: saved.unwrap_or_else(|| value.clone()), + default: value, + latched: false, + pending: None, + }, + ); Ok(()) })?, )?; @@ -1133,7 +1220,7 @@ fn install_nameplate_verbs(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { - use super::MultisampleFormat; + use super::{MultisampleFormat, SeededCvar}; use crate::script::UiScript; fn script_with_volume() -> UiScript { @@ -1170,6 +1257,113 @@ mod tests { assert!(s.take_cvar_changes().is_empty()); } + /// **A latched row stages the write and keeps answering the applied value** (decision 2303) + /// — the reference's `Set 0x63df50` on flag bit1: `latchedValue` takes the string, `InternalSet` + /// never runs, so `GetCVar` (which reads `rec+0x20`) answers the old value until + /// `CVar::Update 0x63e060` commits it. The host still hears every staged write through the + /// change queue (it holds the pending copy), and its commit arrives as a host write, which + /// clears the stage. Writing the applied value back over a stage clears the stage too. + #[test] + fn a_latched_row_stages_the_write_until_the_host_commits_it() { + let mut s = UiScript::new().unwrap(); + s.seed_cvars([ + SeededCvar { + name: "gxVSync".into(), + value: "1".into(), + default: "1".into(), + latched: true, + }, + SeededCvar { + name: "MusicVolume".into(), + value: "0.4".into(), + default: "0.4".into(), + latched: false, + }, + ]); + s.run(r#"SetCVar("gxVSync", 0)"#).unwrap(); + assert_eq!( + s.eval::(r#"return GetCVar("gxVSync")"#).unwrap(), + "1", + "the applied value stands until the boundary" + ); + assert_eq!( + s.take_cvar_changes(), + vec![("gxVSync".to_string(), "0".to_string())], + "the host hears the staged write" + ); + // Staging the same value again is quiet; staging the applied value back clears the stage + // and is reported, so the host can drop its pending copy too. + s.run(r#"SetCVar("gxVSync", "0")"#).unwrap(); + assert!(s.take_cvar_changes().is_empty()); + s.run(r#"SetCVar("gxVSync", "1")"#).unwrap(); + assert_eq!( + s.take_cvar_changes(), + vec![("gxVSync".to_string(), "1".to_string())] + ); + assert!( + s.take_cvar_changes().is_empty(), + "nothing staged, nothing to say" + ); + // The commit is a host write: the value moves, the stage clears, no echo. + s.run(r#"SetCVar("gxVSync", 0)"#).unwrap(); + s.take_cvar_changes(); + s.set_cvar_host("gxVSync", "0"); + assert_eq!( + s.eval::(r#"return GetCVar("gxVSync")"#).unwrap(), + "0" + ); + assert!(s.take_cvar_changes().is_empty()); + // An unlatched sibling is untouched by any of this. + s.run(r#"SetCVar("MusicVolume", 0.7)"#).unwrap(); + assert_eq!( + s.eval::(r#"return GetCVar("MusicVolume")"#) + .unwrap(), + "0.7" + ); + } + + /// **An addon's `RegisterCVar` reaches the host** (decision 2303): the row it creates is + /// reported once, with the declared default, so the host's registry — the store that + /// outlives this VM — can carry it. A re-declaration of a live name is the no-op it always + /// was, and reports nothing. + #[test] + fn an_addon_registration_is_reported_to_the_host_once() { + let mut s = UiScript::new().unwrap(); + s.run(r#"RegisterCVar("myAddonKnob", "7")"#).unwrap(); + s.run(r#"RegisterCVar("myAddonKnob", "9")"#).unwrap(); + assert_eq!( + s.take_cvar_registrations(), + vec![("myAddonKnob".to_string(), "7".to_string())] + ); + assert!(s.take_cvar_registrations().is_empty(), "drained"); + assert_eq!( + s.eval::(r#"return GetCVar("myAddonKnob")"#) + .unwrap(), + "7" + ); + } + + /// **A bare CVar name through `ConsoleExec` is the host's to answer** (decision 2303): the + /// reference's per-CVar console command prints `CVar "%s" is "%s"` on an empty argument + /// (`0x63dde0`), and that printing lives host-side with the rest of the command registry. + /// A name WITH a value is still written synchronously, so the next Lua line reads it back. + #[test] + fn console_exec_writes_a_valued_cvar_and_hands_a_bare_name_to_the_host() { + let mut s = script_with_volume(); + s.run(r#"ConsoleExec("MusicVolume 0.2")"#).unwrap(); + assert_eq!( + s.eval::(r#"return GetCVar("MusicVolume")"#) + .unwrap(), + "0.2" + ); + assert!( + s.take_console_lines().is_empty(), + "a valued write is consumed here" + ); + s.run(r#"ConsoleExec("MusicVolume")"#).unwrap(); + assert_eq!(s.take_console_lines(), vec!["MusicVolume".to_string()]); + } + /// **`SetCVar`'s third argument is the `CVAR_UPDATE` token** (decision 1140) — the whole /// mechanism behind that event. 1.12's options panels pass their CheckButtons table KEY /// (`SetCVar(value.cvar, value.value, index)`), an uppercase display name, and the engine diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index d9812526..438f6aa3 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -201,7 +201,7 @@ pub use cursor::{ CursorPetAction, CursorSpell, CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG, }; pub use cvars::{ - MultisampleFormat, ScreenResolution, VideoCaps, CVAR_FRILL_DENSITY, CVAR_GAMMA, + MultisampleFormat, ScreenResolution, SeededCvar, VideoCaps, CVAR_FRILL_DENSITY, CVAR_GAMMA, CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS, CVAR_WORLD_DETAIL, VIDEO_DEFAULT_CVARS, WORLD_DETAIL_STOPS, }; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 1559a5ab..5995ef85 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -789,6 +789,9 @@ pub(crate) struct Model { /// `(registered name, new value)` per Lua `SetCVar` since the app's last /// [`super::UiScript::take_cvar_changes`] drain — the knob-sync + config-dirty cue. pub(crate) cvar_changes: Vec<(String, String)>, + /// `(name, default)` per addon `RegisterCVar` that created a slot, since the host's last + /// [`super::UiScript::take_cvar_registrations`] drain (decision 2303). + pub(crate) cvar_registrations: Vec<(String, String)>, /// Unknown CVar names already warned about (warn-once, the era-atlas-miss posture). pub(crate) cvars_warned: HashSet, @@ -2020,6 +2023,7 @@ impl Model { cvars: HashMap::new(), cvars_saved_base: HashMap::new(), cvar_changes: Vec::new(), + cvar_registrations: Vec::new(), cvars_warned: HashSet::new(), multisample_formats: Vec::new(), screen_resolutions: Vec::new(), diff --git a/crates/benilla-world/src/clutter.rs b/crates/benilla-world/src/clutter.rs index 8a5d7929..922e0d4f 100644 --- a/crates/benilla-world/src/clutter.rs +++ b/crates/benilla-world/src/clutter.rs @@ -58,9 +58,9 @@ impl Plugin for ClutterPlugin { /// global is read at draw time and needs no rebuild; ours costs a re-mesh of the ~30 chunks in the /// bubble, which the per-frame cap spreads over a few frames. /// -/// Watches the **value**, not `is_changed()`, for the reason `terrain_stream::rescatter_clutter` -/// spells out: the cvar sync deref-muts every knob resource whenever any cvar moves, so the flag -/// over-fires. First sight only arms. +/// Watches the **value**, not `is_changed()`, because the predicate is "the cutout moved" and +/// not "the resource moved": `ClutterConfig` also carries the density, whose own writer would +/// otherwise cost a re-mesh of the bubble on every detail-slider notch. First sight only arms. fn remesh_on_cutout_change( mut commands: Commands, cfg: Res, diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index a804f585..fdfa3fdd 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -1409,9 +1409,9 @@ fn despawn_tile_owned(commands: &mut Commands, t: &TileState) { /// tail-calls the chunk-rebuild walk (`0x6725a0` → `0x6b1d20`, wow-re terrain.md), so a change /// re-scatters the LOADED tiles too, not just future streams. The fresh `ClutterChunk`s spawn /// unbuilt and the lazy builder re-meshes the ~70 yd bubble over the next frames. Watches the -/// VALUE, not `is_changed()`: the cvar sync's `Knobs` construction deref-muts every knob -/// resource whenever any cvar moves, so the flag over-fires (cvars.rs notes the same trap). -/// First sight only arms. +/// VALUE, not `is_changed()`, because the predicate is "the density moved" and not "the resource +/// moved": `ClutterConfig` also carries the detail-doodad cutout, whose console command would +/// otherwise re-scatter every loaded tile. First sight only arms. fn rescatter_clutter( mut commands: Commands, mut streamer: ResMut,