diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index a3c465ba..9421efda 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -383,11 +383,25 @@ fn latch_and_dispatch( over_ui: Res, dispatch: Res, mut state: ResMut, + mut same_vm: Local>, ) { state.just.clear(); state.fired.clear(); state.amounts.clear(); + // A latch indexes the dispatch table snapshotted from the VM that latched it. When the VM is + // replaced mid-hold (a `/reload` with a key down), releasing against the NEW table would run + // the wrong addon's `keystate="up"` body — or swallow the release and leave a Held latched + // with no Stop. So latches die with the VM they were made against (decision 1291); a key + // still physically down re-latches on its next press edge. The reference keeps a held key + // running through a `ReloadUI` (its dispatch is engine-side) — dropping is the safe + // divergence, over the moment the key is pressed again. + if let Some(script) = script.as_ref() { + if same_vm.claim(script) { + state.latched.clear(); + } + } + let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight); let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight); let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight); @@ -552,8 +566,13 @@ fn latch_and_dispatch( } } - // ── Wheel ── a notch is a press with no release (which is exactly why the table refuses - // wheel chords on runOnUp commands); over UI the wheel belongs to the hovered frame. + // ── Wheel ── **a notch is a press AND its release, back to back.** The reference builds one + // chord and hands it to `CBindings::ExecuteBinding` twice — `isDown=1` at `0x483d6f`, then + // `isDown=0` at `0x483d82` (wow-re `system/ui/ui.md` §3, VERIFIED) — so a `runOnUp` command + // runs both halves in the same frame and a plain one runs its single half (the up leg is the + // `RunCommand 0x4b7b50` no-op: `UP + !runOnUp` returns without running anything). Before this + // the notch was a press with no release, which quietly made every press+release command a + // dead wheel binding. Over UI the wheel belongs to the hovered frame. // Trackpads report pixel deltas — normalized to line-equivalents so the zoom consumer's // feel is unchanged from when it read the scroll itself. let wheel = match scroll.unit { @@ -575,25 +594,22 @@ fn latch_and_dispatch( key, }; match dispatch.resolve(chord, false) { - Some(Bound::Spec(cmd)) => match &SPECS[cmd.0 as usize].kind { - Kind::Edge(lua) => run_lua(&mut script, lua, SPECS[cmd.0 as usize].name), - Kind::Host => { - state.fired.push(cmd); - state.amounts.push((cmd, amount)); - } - // Unreachable by construction (SetBinding refuses these); harmless if a - // hand-edited file smuggles one in. - Kind::Held | Kind::EdgeUpDown(..) => {} - }, - // An addon binding on the wheel is a one-shot press, the `Kind::Edge` case. A - // `runOnUp` one cannot be here at all — its entry carries the same press+release flag - // a Held/EdgeUpDown command does, and the table's wheel refusal reads exactly that. - Some(Bound::Addon(i)) => { - if let Some(a) = dispatch.addons.get(i as usize) { - if !a.run_on_up { - run_addon(&mut script, a, "down"); - } - } + // A host command is the one thing the press/release pair cannot carry: its input is + // the notch's ANALOG MAGNITUDE (the camera zoom's), where [`press`] can only spend + // the reference's 1.0 key step. It has no release half either way. + Some(Bound::Spec(cmd)) if matches!(SPECS[cmd.0 as usize].kind, Kind::Host) => { + state.fired.push(cmd); + state.amounts.push((cmd, amount)); + } + // Everything else is the reference's own pair. `Kind::Edge` runs its one body and + // has nothing to release; `Kind::EdgeUpDown` runs BOTH halves now (a wheel-bound + // action button presses and releases in the notch, which is what makes it cast); an + // addon's `runOnUp` body runs twice, `keystate` "down" then "up". `Kind::Held` + // latches and unlatches before any consumer can observe it — which is the + // reference's behaviour too, its movement bit being set and cleared in the one tick. + Some(bound) => { + press(&mut state, &mut script, run_lua, &dispatch, bound, key); + release(&mut state, &mut script, run_lua, &dispatch, key); } None => {} } @@ -985,6 +1001,160 @@ mod tests { ); } + /// **The armed capture seam, driven by real input events** (B265). The page's own tests call + /// `KeyBindings_OnHostKey` directly, so nothing asserted that a real notch/press ever + /// produces that call — and the wheel is the one input that reaches this branch through + /// neither `KeyboardInput` nor `MouseButtonInput`. + #[test] + fn an_armed_capture_takes_a_wheel_notch() { + let mut script = UiScript::new().expect("VM"); + script.register_bindings(®istry_commands()); + script + .run( + r#"CAPTURED = nil + function KeyBindings_OnHostKey(chord) CAPTURED = chord end + BenillaBindCapture(true)"#, + ) + .expect("arm"); + let mut app = vm_harness(script); + + app.world_mut().write_message(MouseWheel { + unit: MouseScrollUnit::Line, + x: 0.0, + y: 1.0, + window: Entity::PLACEHOLDER, + }); + app.update(); + assert_eq!( + lua_str(&app, "tostring(CAPTURED)"), + "MOUSEWHEELUP", + "a wheel notch while armed is a binding key" + ); + assert!( + !state(&app).fired(cmd::CAMERA_ZOOM_IN), + "the armed seam swallows the notch — it must not also zoom" + ); + + // Down, and a modified notch: the canonical prefix order rides the same path. + app.world_mut().write_message(MouseWheel { + unit: MouseScrollUnit::Line, + x: 0.0, + y: -1.0, + window: Entity::PLACEHOLDER, + }); + app.update(); + assert_eq!(lua_str(&app, "tostring(CAPTURED)"), "MOUSEWHEELDOWN"); + press_key(&mut app, KeyCode::ShiftLeft); + app.world_mut().write_message(MouseWheel { + unit: MouseScrollUnit::Line, + x: 0.0, + y: 1.0, + window: Entity::PLACEHOLDER, + }); + app.update(); + assert_eq!(lua_str(&app, "tostring(CAPTURED)"), "SHIFT-MOUSEWHEELUP"); + } + + /// **The whole wheel-bind path in one harness** (B265): the real Keybindings page, a capsule + /// armed by a real click, a real notch fed the way winit feeds it, and then the bound chord + /// dispatching. The page's own tests call `KeyBindings_OnHostKey` by hand and this module's + /// tests carry no page — between them the join was never asserted. + #[test] + fn a_wheel_notch_binds_through_the_real_page_and_then_dispatches() { + let by_name = + |n: &str| Cmd(SPECS.iter().position(|s| s.name == n).expect("registered") as u16); + let mut s = crate::ui_script::keybindings_tests::harness(); + crate::ui_script::keybindings_tests::on_page(&mut s); + const ROW: &str = "OptionsFrameContainerBodyKeybindingsRow"; + // Expand Movement and arm JUMP's first capsule — JUMP is the classic wheel bind, and one + // of the 1.12 commands that is NOT `runOnUp`, so the reference accepts the wheel on it. + s.run(&format!("{ROW}1Header:Click()")).expect("expand"); + s.run(&format!("{ROW}9Key1Button:Click()")).expect("select"); + assert_eq!( + s.eval::(&format!("return {ROW}9Description:GetText()")) + .unwrap(), + "JUMP" + ); + assert!(s.bind_capture_armed()); + let mut app = vm_harness(s); + + app.world_mut().write_message(MouseWheel { + unit: MouseScrollUnit::Line, + x: 0.0, + y: 1.0, + window: Entity::PLACEHOLDER, + }); + app.update(); + { + let s = app.world().non_send_resource::(); + assert_eq!( + s.eval::(r#"return GetBindingAction("MOUSEWHEELUP")"#) + .unwrap(), + "JUMP", + "a notch on an armed capsule is a bind" + ); + assert!(!s.bind_capture_armed(), "the completed bind disarms"); + } + + // …and the bound chord now dispatches: the next notch jumps rather than binding. + app.world_mut().write_message(MouseWheel { + unit: MouseScrollUnit::Line, + x: 0.0, + y: 1.0, + window: Entity::PLACEHOLDER, + }); + app.update(); + assert!(state(&app).fired(by_name("JUMP"))); + assert!( + !state(&app).fired(cmd::CAMERA_ZOOM_IN), + "JUMP stole the wheel from the camera, the 1.12 steal law" + ); + } + + /// **A notch is a press AND its release** — the reference dispatches the one chord twice, + /// `isDown=1` then `isDown=0` (`0x483d6f` / `0x483d82`). So a `runOnUp` binding on the wheel + /// runs both halves in the same frame; before this it ran neither, and the binding was dead. + /// + /// Seeded through the stored set rather than `SetBinding`, because the table refuses to put a + /// wheel chord on a press+release command — this is the "hand-edited file smuggles one in" + /// case, which is exactly the state the old arm silently dropped on the floor. + #[test] + fn a_wheel_notch_runs_both_halves_of_a_press_and_release_binding() { + let mut script = UiScript::new().expect("VM"); + script.register_bindings(®istry_commands()); + script.register_addon_bindings( + "ProbeAddon", + &benilla_ui::bindings_xml::parse(PROBE_BINDINGS).expect("well-formed"), + ); + script.seed_binding_set( + 1, + Some(vec![( + "PROBEHOLD".to_string(), + vec!["MOUSEWHEELUP".to_string()], + )]), + ); + script.load_binding_set(1); + let mut app = vm_harness(script); + + app.world_mut().write_message(MouseWheel { + unit: MouseScrollUnit::Line, + x: 0.0, + y: 1.0, + window: Entity::PLACEHOLDER, + }); + app.update(); + assert_eq!(lua_count(&app, "PROBE_DOWN"), 1, "the notch's press half"); + assert_eq!( + lua_count(&app, "PROBE_UP"), + 1, + "…and its release half, in the same frame — a notch has no key left to lift" + ); + assert_eq!(lua_str(&app, "PROBE_LAST"), "up"); + // Nothing is left latched: a wheel latch that outlived its notch would hold the down + // state forever, with no key to press to end it. + assert!(app.world().resource::().latched.is_empty()); + } + #[test] fn held_commands_latch_across_frames_and_release_per_base_key() { let mut app = harness(); diff --git a/crates/benilla-app/src/bindings/commands.rs b/crates/benilla-app/src/bindings/commands.rs index c944ae23..a238dd29 100644 --- a/crates/benilla-app/src/bindings/commands.rs +++ b/crates/benilla-app/src/bindings/commands.rs @@ -1124,4 +1124,82 @@ mod tests { assert!(seen.insert(s.name), "duplicate command {}", s.name); } } + + /// **The registry against the real client's own two files.** Two columns of this table are + /// pure transcription from the 1.12 install, and both are load-bearing in a way nothing else + /// here can see: `runOnUp` — the mousewheel-refusal law rides it, and B265 turned entirely on + /// whether ours matched — and every command's default chords. A hand-typed table drifts; this + /// reads the source of truth instead of trusting the typing. + /// + /// `Interface\FrameXML\Bindings.xml` carries the flag, `WTF\DefaultBindings.wtf` the chords. + /// Both are install content, read at runtime, never committed; the test skips (passes) + /// without a client, the house pattern for a real-data test. Running Blizzard's own 234-row + /// file through our [`benilla_ui::bindings_xml`] is a second gate riding along — that is the + /// parser every addon's `Bindings.xml` lands in. + #[test] + fn the_registry_matches_the_installs_own_bindings() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open the 1.12 patch chain"); + + let xml = String::from_utf8_lossy( + &chain + .read_file("Interface\\FrameXML\\Bindings.xml") + .expect("the install carries Bindings.xml"), + ) + .into_owned(); + let reference = benilla_ui::bindings_xml::parse(&xml).expect("Blizzard's own file parses"); + let run_on_up: std::collections::HashMap<&str, bool> = reference + .iter() + .map(|b| (b.name.as_str(), b.run_on_up)) + .collect(); + + // `bind `, in the file's own order — which is the order the command's + // Key 1 and Key 2 slots take. + let wtf = String::from_utf8_lossy( + &chain + .read_file("WTF\\DefaultBindings.wtf") + .expect("the install carries DefaultBindings.wtf"), + ) + .into_owned(); + let mut defaults: std::collections::HashMap<&str, Vec<&str>> = + std::collections::HashMap::new(); + for line in wtf.lines() { + let mut it = line.split_whitespace(); + if let (Some("bind"), Some(chord), Some(command), None) = + (it.next(), it.next(), it.next(), it.next()) + { + defaults.entry(command).or_default().push(chord); + } + } + + for s in SPECS { + let Some(&reference_run_on_up) = run_on_up.get(s.name) else { + panic!( + "{} is not a 1.12 binding at all — the tree is meant to be honest", + s.name + ); + }; + assert_eq!( + s.run_on_up(), + reference_run_on_up, + "{}: runOnUp disagrees with the install's Bindings.xml — that flag decides \ + whether the mousewheel may be bound to this command (B265)", + s.name + ); + let ours: Vec<&str> = [s.d1, s.d2].into_iter().flatten().collect(); + if s.name == "OPENALLBAGS" { + // The one recorded divergence (0997): 1.12 splits `B`/TOGGLEBACKPACK from + // `SHIFT-B`/OPENALLBAGS, and benilla's single bag knob is the open-all toggle + // that already lived on B — so it ships wearing both. + assert_eq!(ours, ["B", "SHIFT-B"]); + continue; + } + assert_eq!( + ours, + defaults.get(s.name).cloned().unwrap_or_default(), + "{}: default chords disagree with the install's DefaultBindings.wtf", + s.name + ); + } + } } diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 7ef207f8..c2cffdd4 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -347,6 +347,18 @@ fn sync_cvars( return; }; if persist.registered.claim(&script) { + // 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 + .file + .iter() + .filter(|(k, _)| !persist.env_overridden.contains(&k.to_ascii_lowercase())) + .map(|(k, v)| (k.clone(), v.clone())), + ); script.register_cvars(REGISTERED.iter().copied()); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); let session: [(&str, String); 23] = [ @@ -409,6 +421,106 @@ fn sync_cvars( } } +/// Fold the dying VM's CVar table into the persist state — 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. +#[allow(clippy::type_complexity)] // one Option per knob resource, the same census sync_cvars keeps +pub(crate) fn fold_dying_vm_cvars(world: &mut World) { + // Every param is `Option` because this runs from an exclusive edge function, not the app's + // schedule: a test world or a stripped scenario may not carry the cvar plugin at all, and + // "no persist state" simply means there is no file to bridge — not a panic. + let mut state: bevy::ecs::system::SystemState<( + Option>, + Option>, + Option>, + Option>, + Option>, + Option>, + Option>, + Option>, + Option>, + Option>, + Option>, + Option>, + Option>, + )> = bevy::ecs::system::SystemState::new(world); + let ( + script, + persist, + sound, + scale, + view, + look, + click, + loot, + names, + clutter, + minimap, + bubbles, + zoom, + ) = state.get_mut(world); + let (Some(mut script), Some(mut persist)) = (script, persist) else { + return; + }; + let changes = script.take_cvar_changes(); + if !changes.is_empty() { + // The knobs exist whenever the app is real (each plugin inits its own); a world that + // carries CvarPersist but not the knobs is a partial test rig, where dropping the + // final-frame drain is the right degradation — the fold below still preserves the file. + if let ( + Some(mut sound), + Some(mut scale), + Some(mut view), + Some(mut look), + Some(mut click), + Some(mut loot), + Some(mut names), + Some(mut clutter), + Some(mut minimap), + Some(mut bubbles), + Some(mut zoom), + ) = ( + sound, scale, view, look, click, loot, names, clutter, minimap, bubbles, zoom, + ) { + let mut knobs = Knobs { + sound: &mut sound, + scale: &mut scale, + view: &mut view, + look: &mut look, + click: &mut click, + loot: &mut loot, + names: &mut names, + clutter: &mut clutter, + minimap: &mut minimap, + bubbles: &mut bubbles, + zoom: &mut zoom, + }; + for (name, value) in changes { + if apply_to_knobs(&name, &value, &mut knobs) { + persist.dirty = true; + persist.last_change = Some(Instant::now()); + } + } + } + } + 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.env_overridden, &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 — env-overridden keys /// untouched (the session value is the env's, not the player's). diff --git a/crates/benilla-app/src/death.rs b/crates/benilla-app/src/death.rs index 4639a852..b8531f56 100644 --- a/crates/benilla-app/src/death.rs +++ b/crates/benilla-app/src/death.rs @@ -81,18 +81,37 @@ pub(crate) struct DeathNet { pub(crate) water_walk: bool, } -/// The feed's change-tracking memory (the [`crate::ui_unit::UnitFeedState`] pattern — guid-keyed, -/// first-snapshot-counts-as-edge so logging in dead brings the popup up). +/// The feed's memory, in **two scopes** (decisions 1290/1291): the body's own state machine is +/// world memory and survives a `/reload`; what this VM has been *told* dies with the VM. +/// +/// The reference draws the same line by construction — its death mirror is engine-side (wow-re +/// death-ui.md §1), so a `ReloadUI` neither re-arms the release window nor loses it, while the +/// rebuilt UI repaints from the surviving state. Ours mirrors that: `mirror`/`died_at` stay put +/// across a VM replacement, and the announce latches reset with the VM so the fresh frame tree +/// hears the events the old one consumed. #[derive(Resource, Default)] struct DeathFeedState { - /// The last `(guid, dead, ghost)` we saw for the self player; `None` before the first - /// snapshot (and after a despawn — a worldport recreates the entity, same guid). - last: Option<(u64, bool, bool)>, - /// `Time::elapsed_secs_f64` at the death edge — the anchor of the client-side release window - /// (byte-VERIFIED, wow-re death-ui.md §1: the wire carries only the PLAYER_FIELD_BYTES timer - /// BIT; the client arms `now + 360000 ms` at its own alive→dead mirror-edge — so a - /// login-while-dead re-arms at login exactly like ours, and expiry reads 0, never negative). + /// **World-scoped.** The last `(guid, dead, ghost)` the self body showed — the edge that + /// arms and clears the release window. `None` before a world session's first snapshot. + mirror: Option<(u64, bool, bool)>, + /// **World-scoped.** `Time::elapsed_secs_f64` at the death edge — the anchor of the + /// client-side release window (byte-VERIFIED, wow-re death-ui.md §1: the wire carries only + /// the PLAYER_FIELD_BYTES timer BIT; the client arms `now + 360000 ms` at its own + /// alive→dead mirror-edge — so a login-while-dead re-arms at login exactly like ours, and + /// expiry reads 0, never negative). Deliberately NOT per-VM: a `/reload` must not restart + /// the six minutes — the reference's engine-side window keeps running through one. died_at: Option, + /// **VM-scoped** — the announce latches, keyed on the VM they announced to. + vm: crate::ui_script::VmMemo, +} + +/// What the live VM has been told about the death state — [`DeathFeedState::vm`]'s payload +/// (guid-keyed, first-snapshot-counts-as-edge so logging in dead brings the popup up — and so +/// does reloading dead, which is the same situation from the frame tree's point of view). +#[derive(Default)] +struct DeathAnnounced { + /// The last `(guid, dead, ghost)` THIS VM was given an event for; `None` for a fresh VM. + last: Option<(u64, bool, bool)>, /// The last corpse-range verdict announced to the UI (`None` = nothing announced): the /// CORPSE_IN_RANGE / CORPSE_OUT_OF_RANGE / CORPSE_IN_INSTANCE edge memory (decision 0308 §5). corpse_range: Option, @@ -196,20 +215,19 @@ fn feed_death( sickness_duration: sickness_duration(store.0.unit_level().unwrap_or(0)), }); - // ── The state-machine edges (classic event semantics — module doc) ───────────────────────── - let prev = feed.last.filter(|&(g, ..)| g == guid); - feed.last = Some((guid, dead, ghost)); + // ── The WORLD edges (the body's own state machine): the release-window anchor and the + // corpse asks. These run off `mirror`, which survives a VM replacement — a `/reload` while + // dead must neither restart the six minutes nor re-query the corpse (the cache has it). + let prev = feed.mirror.filter(|&(g, ..)| g == guid); + feed.mirror = Some((guid, dead, ghost)); match (prev, dead, ghost) { - // Alive → dead (and the login-while-dead first snapshot): the DEATH popup's trigger. + // Alive → dead (and the login-while-dead first snapshot): arm the release window. (Some((_, false, false)) | None, true, false) => { feed.died_at = Some(now); - script.fire_event("PLAYER_DEAD", vec![]); } - // Dead-unreleased → ghost (the release landed: ghost aura + graveyard teleport), or → - // alive (a pre-release res): both are the classic PLAYER_ALIVE. + // Dead-unreleased → ghost (the release landed), or → alive (a pre-release res). (Some((_, true, false)), d, g) if g || !d => { feed.died_at = None; - script.fire_event("PLAYER_ALIVE", vec![]); if g { // Now a ghost: ask where the corpse is (feeds the slice-2 markers + range gate). let _ = net.0.send(ClientCommand::CorpseQuery); @@ -218,7 +236,6 @@ fn feed_death( // Ghost → alive (reclaim / spirit healer / accepted res). (Some((_, _, true)), false, false) => { feed.died_at = None; - script.fire_event("PLAYER_UNGHOST", vec![]); // Re-ask where the corpse is — the answer is the authoritative NOT-FOUND that drops // the map markers. The server's own "corpse gone" push is LOOTER-gated (vmangos // Map.cpp:3617-3629: the unprompted u8(0) goes out only when a PvP looter exists), so @@ -228,16 +245,36 @@ fn feed_death( // Director-reported: the map tombstone survived a spirit-healer res without this. let _ = net.0.send(ClientCommand::CorpseQuery); } - // Logging in already a ghost: no edge event (nothing was open), just the corpse ask. + // Logging in already a ghost: no edge, just the corpse ask. (None, _, true) => { let _ = net.0.send(ClientCommand::CorpseQuery); } _ => {} } + // ── The VM edges: the classic events, fired off what THIS VM has heard (module doc). A + // fresh VM's memo is empty, so a reload-while-dead re-fires PLAYER_DEAD to the rebuilt + // frame tree — the same first-snapshot-counts-as-edge that brings the popup up at a + // login-while-dead, which is our design's own posture for both (decision 1291). + let memo = feed.vm.get(&script); + let prev = memo.last.filter(|&(g, ..)| g == guid); + memo.last = Some((guid, dead, ghost)); + match (prev, dead, ghost) { + (Some((_, false, false)) | None, true, false) => { + script.fire_event("PLAYER_DEAD", vec![]); + } + (Some((_, true, false)), d, g) if g || !d => { + script.fire_event("PLAYER_ALIVE", vec![]); + } + (Some((_, _, true)), false, false) => { + script.fire_event("PLAYER_UNGHOST", vec![]); + } + _ => {} + } + // ── The offer/confirm announcements (edge-fired, name-gated) ─────────────────────────────── match &death_net.resurrect { - Some(offer) if !feed.offer_announced => { + Some(offer) if !memo.offer_announced => { // A player caster's wire name is empty — resolve through the ask-once name cache and // hold the popup until it lands (the ref popup formats "%s wants to resurrect you"). let name = if offer.name.is_empty() { @@ -246,18 +283,18 @@ fn feed_death( Some(offer.name.clone()) }; if let Some(name) = name { - feed.offer_announced = true; + memo.offer_announced = true; script.fire_event("RESURRECT_REQUEST", vec![ScriptValue::Str(name)]); } } - None => feed.offer_announced = false, + None => memo.offer_announced = false, _ => {} } // The confirm is message-fired, not state-edged (decision 1068): each SMSG bumps the // generation, so asking the healer again after a Cancel brings the dialog back. - if death_net.spirit_healer.is_some() && feed.confirm_generation != death_net.confirm_generation + if death_net.spirit_healer.is_some() && memo.confirm_generation != death_net.confirm_generation { - feed.confirm_generation = death_net.confirm_generation; + memo.confirm_generation = death_net.confirm_generation; script.fire_event("CONFIRM_XP_LOSS", vec![]); } @@ -286,22 +323,22 @@ fn feed_death( } else { None }; - if feed.reclaim_generation != death_net.reclaim_generation { - feed.reclaim_generation = death_net.reclaim_generation; - feed.corpse_range = None; // re-announce whatever holds now (the client's 0x269 re-fire) + if memo.reclaim_generation != death_net.reclaim_generation { + memo.reclaim_generation = death_net.reclaim_generation; + memo.corpse_range = None; // re-announce whatever holds now (the client's 0x269 re-fire) } - if range != feed.corpse_range { + if range != memo.corpse_range { match range { Some(CorpseRange::In) => script.fire_event("CORPSE_IN_RANGE", vec![]), Some(CorpseRange::InInstance) => script.fire_event("CORPSE_IN_INSTANCE", vec![]), // Leaving range — and the corpse/ghost state ENDING while a range dialog could be // up — both land on the same hide event (the ref's OUT arm hides all three). - Some(CorpseRange::Out) | None if feed.corpse_range.is_some() => { + Some(CorpseRange::Out) | None if memo.corpse_range.is_some() => { script.fire_event("CORPSE_OUT_OF_RANGE", vec![]); } _ => {} } - feed.corpse_range = range; + memo.corpse_range = range; } } diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index 7585f63e..b9fee33d 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -833,7 +833,10 @@ pub(super) fn attach_entity_visuals( benilla_world::ribbons::RibbonSeq::Host(entity), // The unit's own render alpha gates its trail (0827). Some(entity), - None, // a streamed unit belongs to no building's doodad set + // No fade sphere: a streamed unit is not a placed model — its + // population is bounded by server visibility, and its trail by the + // unit's own render alpha one line up. + None, ); } } diff --git a/crates/benilla-app/src/entities/equipment/spawn.rs b/crates/benilla-app/src/entities/equipment/spawn.rs index 0600b6f9..9679e6f4 100644 --- a/crates/benilla-app/src/entities/equipment/spawn.rs +++ b/crates/benilla-app/src/entities/equipment/spawn.rs @@ -665,7 +665,9 @@ fn spawn_slot( // Its own model instance — chained to the wearer above — so an enchant streamer is // gone with the avatar in first person and absent until the body is shown (0827/0833). Some(root), - None, // a carried item belongs to no building's doodad set + // No fade sphere: a carried item is not a placed model — it rides its wearer's + // residency, and its streamer the wearer's render alpha one line up. + None, ); } debug!( diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs index bdffcaf2..ddacc867 100644 --- a/crates/benilla-app/src/entities/spell_fx/mod.rs +++ b/crates/benilla-app/src/entities/spell_fx/mod.rs @@ -466,7 +466,9 @@ pub(crate) fn attach_effect_visuals( // This instance's own model alpha, chained to its host (0827/0833): a standalone // instance has none above it and draws exactly as before. Some(root), - None, // an effect instance belongs to no building's doodad set + // No fade sphere: an effect instance is not a placed model — it lives for its + // clip and is gated by its own model alpha one line up. + None, ); } true diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index e3bdc060..7a2e6e74 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -370,11 +370,19 @@ pub(super) fn spawn_wmo_gameobject_props( benilla_world::ribbons::RibbonSeq::Host(entity), // No model-alpha source: a placed prop / effect instance is always drawn (0827). None, - // A GameObject's props ride the GameObject, not a portal-culled placement: - // this WMO is the boat, and nothing floods it. (Its off-map hide reaches - // the trail no better than before — a `Visibility`-hidden transport still - // trails. No 1.12 transport prop authors a ribbon, so nothing renders it - // today; it is named in decision 1289's residuals rather than guessed at.) + // No fade sphere: a GameObject's props ride the GameObject, not a + // baked world placement — that gate's centre is a fixed world point and a + // mover would drag away from it (the same reason the emitters beside them + // take none, stated at the top of this file), and nothing floods this WMO + // to give it rooms. The far-clip wall still applies: `simulate_ribbons` + // bounds a fade-less trail at the wall exactly as `simulate_particles` + // bounds a fade-less emitter, which is what a map-wide-streamed transport + // five kilometres away needs (decision 0678). + // + // Still open, and named rather than guessed at: a `Visibility`-hidden + // transport trails. The trail reads its owner's render alpha, not its + // `Visibility`, and the off-map hide writes the latter. No 1.12 transport + // prop authors a ribbon, so nothing renders it today. None, ) .is_some() diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 7b4c2515..61ae05e4 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -196,11 +196,26 @@ fn reconcile(cache: &mut Vec, live: &[UnitAuraSlot], now: f64) { /// a `UNIT_AURA` trigger. type AuraProjection = (u32, u8, bool, Option); -/// The feed's edge-detection memory: per token, the projection of the list last pushed. A resource -/// rather than a `Local` (the [`crate::ui_unit::UnitFeedState`] pattern) because the session-end -/// teardown resets it from its own system — see [`end_session_aura_state`]. +/// The feed's edge-detection memory: per token, the projection of the list last pushed (the +/// [`crate::ui_unit::UnitFeedState`] resource shape). +/// +/// The edge keys live behind a [`crate::ui_script::VmMemo`]: what we last told the VM is a claim +/// about THAT VM (1290), so against a `/reload`'s replacement (1291) — which swaps the VM while +/// every aura stays live — the memo reads fresh and `UNIT_AURA` re-fires for the new frames. That +/// key is also what retired the explicit reset [`end_session_aura_state`] used to run: the memo +/// dies with the VM, while that system keeps its real job, dropping the server-side aura state. #[derive(Resource, Default)] struct AuraFeedMemory { + /// What we last told the VM — dies with the VM it was told to. + vm: crate::ui_script::VmMemo, + /// The Bevy-clock instant [`trace_timers`] last printed (`BENILLA_AURA_TRACE`). Host + /// diagnostics, not VM memory — the trace cadence outlives any one VM, deliberately. + traced_at: f64, +} + +/// The per-VM half of [`AuraFeedMemory`] — the `UNIT_AURA` edge keys. +#[derive(Default)] +struct AuraFeedMemo { present: bool, last: Vec, /// The tracking spell last pushed ([`tracking_state_of`]) — part of the player half's change @@ -214,8 +229,6 @@ struct AuraFeedMemory { /// The pet half, same shape and same reason (decision 0990): a stable swap between two pets /// carrying identical auras must still re-fire, so the guid joins the key. pet_last: Option<(u64, Vec)>, - /// The Bevy-clock instant [`trace_timers`] last printed (`BENILLA_AURA_TRACE`). - traced_at: f64, } /// The `BENILLA_AURA_TRACE` period in seconds, or `None` when the trace is off. A set-but-unparsable @@ -623,14 +636,19 @@ fn feed_auras( } } + // The VM half — resolved against THIS VM, so a `/reload`'s replacement (1291) reads a fresh + // memo and every edge below re-fires for it. Taken after the `traced_at` use above: the trace + // cadence is host diagnostics and must not expire with the VM. + let memo = mem.vm.get(&script); + // Edge-trigger UNIT_AURA on a discrete change (the reference's PLAYER_AURAS_CHANGED), never on // the countdown alone — that's a per-frame OnUpdate on the button, not an event. The tracking // spell joins the key: a tracking *switch* changes no visible list, only the minimap icon. let projection = projection_of(&list); - let changed = !mem.present || projection != mem.last || tracking_spell != mem.tracking_last; - mem.last = projection; - mem.tracking_last = tracking_spell; - mem.present = true; + let changed = !memo.present || projection != memo.last || tracking_spell != memo.tracking_last; + memo.last = projection; + memo.tracking_last = tracking_spell; + memo.present = true; // Debug affordance (`BENILLA_AURA_DUMP=1`): when the visible set changes, log every slot the // player's bar will draw — raw slot, spell id, resolved name, class, and the flags nibble. The @@ -715,8 +733,8 @@ fn feed_auras( .guid .zip(target_list.as_deref()) .map(|(guid, l)| (guid, projection_of(l))); - let target_changed = target_cur.is_some() && target_cur != mem.target_last; - mem.target_last = target_cur; + let target_changed = target_cur.is_some() && target_cur != memo.target_last; + memo.target_last = target_cur; // The BENILLA_AURA_DUMP affordance's target half: what the target rows will draw, on change. if target_changed && std::env::var_os("BENILLA_AURA_DUMP").is_some() { @@ -737,8 +755,8 @@ fn feed_auras( .then_some(pet_list.as_deref()) .flatten() .map(|l| (pet_guid, projection_of(l))); - let pet_changed = pet_cur.is_some() && pet_cur != mem.pet_last; - mem.pet_last = pet_cur; + let pet_changed = pet_cur.is_some() && pet_cur != memo.pet_last; + memo.pet_last = pet_cur; script.set_auras("player", Some(list)); // Clearing the token isn't a UNIT_* event (the frame reacts to PLAYER_TARGET_CHANGED, and the @@ -808,7 +826,6 @@ fn end_session_aura_state( script: Option>, mut durations: ResMut, mut cache: ResMut, - mut mem: ResMut, ) { if let Some(mut script) = script { script.set_auras("player", None); @@ -817,7 +834,8 @@ fn end_session_aura_state( } cache.auras.clear(); durations.by_slot.clear(); - *mem = AuraFeedMemory::default(); + // No [`AuraFeedMemory`] reset here: its edge keys live behind a `VmMemo` and die with the VM + // this session's pushes went to (1290/1291) — only the server-side state above needs a hand. } /// Drain the spell ids `CancelUnitBuff` queued this frame and send one `CMSG_CANCEL_AURA` each. The @@ -878,7 +896,6 @@ mod tests { level: 60, stacks: 1, }); - app.world_mut().resource_mut::().present = true; } /// **Decision 0900, pinned.** A cross-map worldport despawns every tracked entity — our avatar @@ -920,10 +937,9 @@ mod tests { app.update(); assert!(app.world().resource::().auras.is_empty()); assert!(app.world().resource::().by_slot.is_empty()); - assert!( - !app.world().resource::().present, - "the edge memory resets too, so the next character's first list counts as an edge" - ); + // The feed's edge memory needs no teardown assertion any more: it sits behind a `VmMemo`, + // so it dies with the VM rather than on this edge — the property `ui_script::session`'s + // own tests pin (1290/1291), and one a VM-less app cannot observe. } fn slot(slot: u8, spell_id: u32) -> UnitAuraSlot { diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index 3a859c50..4ee88a25 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -72,8 +72,19 @@ fn fixed(v: f32) -> i64 { /// The feed's change-tracking memory (the [`crate::ui_unit::UnitFeedState`] shape): the last /// snapshots pushed, so events fire on transitions only. +/// +/// All of it is what-we-told-the-VM, so all of it lives behind a [`crate::ui_script::VmMemo`]: a +/// memory about one VM is unreadable against the next (1290), so a `/reload` (1291) — which +/// replaces the VM without despawning the avatar — re-pushes both snapshots and re-fires their +/// events exactly as a fresh login does. #[derive(Resource, Default)] struct CharFeedState { + vm: crate::ui_script::VmMemo, +} + +/// The per-VM half of [`CharFeedState`] — the transition-diff bases. +#[derive(Default)] +struct CharFeedMemo { last_stats: Option, last_inv: Option, } @@ -808,25 +819,29 @@ fn feed_char( // The pane's Model:SetRotation transcription owns the yaw; the booth mirrors it (0208 §5). booth.yaw = script.paperdoll_yaw(); + // Resolved against THIS VM (1290/1291): a `/reload`'s replacement reads a fresh memo, so both + // snapshots below re-push and their events re-fire for it. + let memo = feed.vm.get(&script); + let Some(store) = self_q.iter().next() else { - if feed.last_stats.is_some() || feed.last_inv.is_some() { + if memo.last_stats.is_some() || memo.last_inv.is_some() { script.set_player_combat_stats(None); script.set_inventory_slots(Default::default()); - feed.last_stats = None; - feed.last_inv = None; + memo.last_stats = None; + memo.last_inv = None; } return; }; let stats = combat_stats(store, &mut items, &commands); - if feed.last_stats.as_ref() != Some(&stats) { + if memo.last_stats.as_ref() != Some(&stats) { // PUSH before firing: event dispatch runs the Lua handlers synchronously, so the snapshot // must already be in the VM when they repaint (the ui_unit rule — a fire-first ordering // paints the OLD values and, being transition-gated, never corrects itself). - let prev = feed.last_stats.take(); + let prev = memo.last_stats.take(); script.set_player_combat_stats(Some(stats.clone())); fire_stat_transitions(&mut script, "player", prev.as_ref(), &stats); - feed.last_stats = Some(stats); + memo.last_stats = Some(stats); } let inv = inventory_slots( @@ -838,13 +853,13 @@ fn feed_char( &pending, &mut names, ); - if feed.last_inv.as_ref() != Some(&inv) { + if memo.last_inv.as_ref() != Some(&inv) { script.set_inventory_slots(inv.clone()); script.fire_event( "UNIT_INVENTORY_CHANGED", vec![ScriptValue::Str("player".to_string())], ); - feed.last_inv = Some(inv); + memo.last_inv = Some(inv); } // `GetWeaponEnchantInfo`'s whole data path (the buff bar's TemporaryEnchantFrame row, plus 8 diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index bf6cd8dc..b11b892b 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -61,6 +61,9 @@ impl Plugin for UiChatPlugin { // Push before the input pass so a line is on screen the same frame it decodes (mirrors // the loot/merchant feeds). .add_systems(Update, feed::feed_chat.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)) // 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. diff --git a/crates/benilla-app/src/ui_chat/channels.rs b/crates/benilla-app/src/ui_chat/channels.rs index 1a25342b..586e7cea 100644 --- a/crates/benilla-app/src/ui_chat/channels.rs +++ b/crates/benilla-app/src/ui_chat/channels.rs @@ -136,6 +136,26 @@ fn end_channel_session( } } +/// Seed a fresh VM's joined-channel mirror (decision 1291). The mirror is otherwise pushed only +/// on the join/leave edges ([`super::feed`]'s YOU_JOINED / YOU_LEFT arms), and a `/reload` +/// replaces the VM *between* edges — `ChannelState` (server truth) survives, but the new VM's +/// mirror would stay empty: `GetChannelName`/`GetChannelList` answer nothing, `/1`-`/9` routing +/// is dead, and every channel line renders unnumbered until the player happens to join or leave +/// something. A login goes through [`end_session_channels`] + the auto-join walk instead, where +/// this claim pushes the just-cleared (empty) list — a no-op by construction. +pub(super) fn seed_channels( + script: Option>, + channels: Res, + mut seeded: Local>, +) { + let Some(mut script) = script else { + return; + }; + if seeded.claim(&script) { + script.set_joined_channels(channels.joined.clone()); + } +} + /// The session-end edge: a confirmed `/logout` back to the glue layer (`OnExit(InWorld)`), which is /// the character switch the director's screenshot caught. pub(super) fn end_session_channels( diff --git a/crates/benilla-app/src/ui_chat/commands.rs b/crates/benilla-app/src/ui_chat/commands.rs index 49b13c00..d18c33c3 100644 --- a/crates/benilla-app/src/ui_chat/commands.rs +++ b/crates/benilla-app/src/ui_chat/commands.rs @@ -82,6 +82,17 @@ pub(crate) enum SlashIndex { MacroUi, /// `/macrohelp` — the reference's own five-line help text. MacroHelp, + /// `/console ` — the reference's `SlashCmdList["CONSOLE"]` pipes into the engine's + /// debug-console command table. Ours implements the one console command this client has: + /// `reloadUI` (the reference's `"reloadUI" 0x82ea38` → `CCommand::ReloadUI 0x4035f0` → + /// `0x491380`, the same flag `ReloadUI()` sets). Anything else answers a plain system line + /// rather than silently doing nothing. + Console, + /// `/reload` — **benilla's own addition** (decision 1291): 1.12 ships `ReloadUI()` and + /// `/console reloadUI` but no slash alias for it (that arrived in later clients — the shipped + /// `GlobalStrings.lua` has no `SLASH_RELOADUI`), so this alias is a literal in [`Self::build`] + /// rather than data read off the chain, exactly like the ESC-menu AddOns window is ours (1197). + ReloadUi, } impl SlashIndex { @@ -122,13 +133,17 @@ impl SlashIndex { Self::Cast => "CAST", Self::MacroUi => "MACRO", Self::MacroHelp => "MACROHELP", + Self::Console => "CONSOLE", + // No shipped alias string exists under this key (the walk finds nothing); the `/reload` + // alias is inserted as a literal in `build` — see the variant's doc. + Self::ReloadUi => "RELOADUI", } } /// Every registered index — the registry proper. A reference command NOT in this list resolves /// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference /// (better than a registered handler that silently does nothing). - const ALL: [Self; 34] = [ + const ALL: [Self; 36] = [ Self::Reply, Self::Join, Self::Leave, @@ -163,6 +178,8 @@ impl SlashIndex { Self::Cast, Self::MacroUi, Self::MacroHelp, + Self::Console, + Self::ReloadUi, ]; } @@ -218,8 +235,8 @@ pub(crate) struct SlashCommands { by_alias: HashMap, /// How many aliases each source contributed — the boot line that makes a broken table (an /// unreadable `GlobalStrings.lua`, a missing token table) visible immediately instead of at - /// the first command someone types. - counts: (usize, usize, usize), + /// the first command someone types. `(slash, emote, benilla additions, dev instruments)`. + counts: (usize, usize, usize, usize), } impl SlashCommands { @@ -270,7 +287,19 @@ impl SlashCommands { } let emote_aliases = by_alias.len() - slash_aliases; - // 3 · benilla's instruments, last so they can never shadow a shipped command. + // 3 · benilla's own player-facing additions — literals, because they are OURS and no + // shipped string carries them (each variant's doc records the why). NOT gated on dev + // affordances: `/reload` belongs to a player build as much as the ESC-menu AddOns window + // does (1197, 1291). Inserted after the walks so a shipped alias could never be shadowed + // even if a later client's strings arrived on the chain. + insert( + &mut by_alias, + "reload", + Command::Slash(SlashIndex::ReloadUi), + ); + let added_aliases = by_alias.len() - slash_aliases - emote_aliases; + + // 4 · benilla's instruments, last so they can never shadow a shipped command. // benilla's own instruments — `/castvis`, `/partytest`, `/chattest`, `/shot`, `/liquid`, // `/reaction`. A player build claims none of the aliases, so typing one falls through to // the reference's "unknown command" exactly as it should (decision 1179). They are gated @@ -287,18 +316,20 @@ impl SlashCommands { Self { by_alias, - counts: (slash_aliases, emote_aliases, dev_aliases), + counts: (slash_aliases, emote_aliases, added_aliases, dev_aliases), } } - /// `(slash aliases, emote aliases, dev aliases)` — how many DISTINCT commands each source made - /// reachable (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2` are both `"/sit"`). The - /// boot report. + /// `(slash aliases, emote aliases, benilla additions, dev aliases)` — how many DISTINCT + /// commands each source made reachable (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2` + /// are both `"/sit"`). The boot report. /// - /// The third number is the seam, **made observable** (decision 1179): benilla's own instrument + /// The last number is the seam, **made observable** (decision 1179): benilla's own instrument /// commands are gated on `run_mode::dev_affordances()`, and a gate nobody can see the effect of /// is a gate nobody checks. A player build must print `0`, and one line of its own log says so. - pub(super) fn counts(&self) -> (usize, usize, usize) { + /// The third — the player-facing additions (`/reload`, 1291) — is deliberately separate from + /// both: present in every build, and never passed off as a shipped command. + pub(super) fn counts(&self) -> (usize, usize, usize, usize) { self.counts } } @@ -330,13 +361,12 @@ pub(crate) fn build_slash_commands( |name| globals.get::(name).ok().filter(|s| !s.is_empty()), |token| emotes.text_id(token), ); - let (slash, emote, dev) = table.counts(); - // The "it loaded" signal: the shipped 1.12 data yields 55 distinct aliases across the - // registered indices and 225 emote commands (pinned by `real_alias_table_resolves_the_shipped_ - // commands`). + let (slash, emote, added, dev) = table.counts(); + // The "it loaded" signal: the shipped 1.12 data yields the pinned alias counts (see + // `real_alias_table_resolves_the_shipped_commands`), plus benilla's own additions (1291). info!( "chat: slash table — {slash} command aliases, {emote} emote aliases, \ - {dev} instrument aliases" + {added} benilla additions, {dev} instrument aliases" ); if emote == 0 { error!("chat: NO emote aliases — every /wave-style command is dead (GlobalStrings?)"); diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 81ea3c00..9fd65cd9 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -758,6 +758,24 @@ 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. + ParsedChat::ReloadUi => { + script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi) + } + ParsedChat::ConsoleUnknown { cmd } => { + let text = if cmd.is_empty() { + "console: no command given (this client implements: reloadUI)".to_string() + } else { + format!( + "console: '{cmd}' is not implemented (this client implements: reloadUI)" + ) + }; + 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. ParsedChat::Lua { body } => { diff --git a/crates/benilla-app/src/ui_chat/input/parse.rs b/crates/benilla-app/src/ui_chat/input/parse.rs index bfe802c8..ed8296a9 100644 --- a/crates/benilla-app/src/ui_chat/input/parse.rs +++ b/crates/benilla-app/src/ui_chat/input/parse.rs @@ -181,6 +181,15 @@ 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. + ReloadUi, + /// A `/console` command that is not `reloadUI` — the engine console this client does not + /// have. 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 slash line matching neither a chat command nor an `EmotesText` name — dropped. Unknown, } @@ -387,6 +396,16 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { body: "ShowMacroFrame()".into(), }, S::MacroHelp => ParsedChat::MacroHelp, + // `/reload` (ours, 1291) and the reference's `/console reloadUI` are the same rebuild. + // The console command match is case-insensitive like the engine's console; `0x4035f0` + // reads no arguments, so anything after `reloadUI` is ignored rather than an error. + S::ReloadUi => ParsedChat::ReloadUi, + S::Console => match args.split_whitespace().next() { + Some(cmd) if cmd.eq_ignore_ascii_case("reloadui") => ParsedChat::ReloadUi, + _ => ParsedChat::ConsoleUnknown { + cmd: args.to_string(), + }, + }, // `/script` = the ref's `RunScript(msg)`: the typed text IS the chunk, un-escaped. S::Script => { if args.is_empty() { diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 41f6b68a..c41f5017 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -1345,12 +1345,17 @@ fn real_alias_table_resolves_the_shipped_commands() { assert_eq!(parse_line("/macrohelp"), ParsedChat::MacroHelp); // The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote // commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2` - // are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **67 distinct - // aliases** across the 34 registered `SlashCmdList` indices (0886 added TARGET's `/target` + // are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **68 distinct + // aliases** across the 36 registered `SlashCmdList` indices (0886 added TARGET's `/target` // `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`; - // 0983 added CAST's `/cast` `/spell`, MACRO's `/macro` `/m`, and MACROHELP's `/macrohelp`). + // 0983 added CAST's `/cast` `/spell`, MACRO's `/macro` `/m`, and MACROHELP's `/macrohelp`; + // 1291 added CONSOLE's `/console` — one distinct alias, SLASH_CONSOLE1 and 2 are both the + // same string). // - // The third number is the **seam** (decision 1179): benilla's own instrument commands + // The third number is benilla's own player-facing additions (1291): `/reload` — present in + // every build, deliberately counted apart from the shipped surface so the seam stays visible. + // + // The fourth is the instrument **seam** (decision 1179): benilla's own instrument commands // (`/castvis` `/chattest` `/partytest` `/shot` `/liquid` `/reaction` `/react` — 7 aliases over 6 // commands) are registered only when `run_mode::dev_affordances()`, so a player build claims // none of them and `/partytest` falls through to the reference's "unknown command". Asserted @@ -1362,8 +1367,8 @@ fn real_alias_table_resolves_the_shipped_commands() { }; assert_eq!( table.counts(), - (67, 225, instruments), - "(slash, emote, instrument) aliases" + (68, 225, 1, instruments), + "(slash, emote, benilla addition, instrument) aliases" ); } diff --git a/crates/benilla-app/src/ui_follow.rs b/crates/benilla-app/src/ui_follow.rs index 77ddd678..53e1bd7a 100644 --- a/crates/benilla-app/src/ui_follow.rs +++ b/crates/benilla-app/src/ui_follow.rs @@ -57,8 +57,19 @@ use crate::ui_script::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. +/// +/// "What THIS VM has been told" is exactly what a [`crate::ui_script::VmMemo`] keys (1290): a +/// `/reload` (1291) replaces the VM mid-follow without touching [`FollowState`], and the fresh +/// memo's `None` makes the next frame re-fire `AUTOFOLLOW_BEGIN` — the replacement VM never heard +/// it, so its status line has to be told again. #[derive(Resource, Default)] pub(crate) struct FollowFeed { + vm: crate::ui_script::VmMemo, +} + +/// The per-VM half of [`FollowFeed`] — the one transition base. +#[derive(Default)] +struct FollowFeedMemo { told: Option, } @@ -71,7 +82,9 @@ fn feed_follow( let Some(mut script) = script else { return; }; - match (follow.guid, feed.told) { + // Resolved against THIS VM (1290/1291): a replacement VM reads `told: None` and gets its BEGIN. + let memo = feed.vm.get(&script); + match (follow.guid, memo.told) { // Started, or switched subject. A switch emits END first, because the reference's arm // routine calls the canceller before arming (see the module header). (Some(guid), told) if told != Some(guid) => { @@ -82,13 +95,13 @@ fn feed_follow( "AUTOFOLLOW_BEGIN", vec![ScriptValue::Str(follow.name.clone())], ); - feed.told = Some(guid); + memo.told = Some(guid); } // Stopped — by arrival at an unresolvable followee, by the turn-away cancel, or by any of // the input cancels. END carries no argument: the frame reuses the name BEGIN latched. (None, Some(_)) => { script.fire_event("AUTOFOLLOW_END", vec![]); - feed.told = None; + memo.told = None; } _ => {} } diff --git a/crates/benilla-app/src/ui_inspect.rs b/crates/benilla-app/src/ui_inspect.rs index 9cf01cdb..a9da4be1 100644 --- a/crates/benilla-app/src/ui_inspect.rs +++ b/crates/benilla-app/src/ui_inspect.rs @@ -83,8 +83,18 @@ pub(crate) struct InspectTarget { /// The last pushed view + the last level seen, so the feed pushes and fires only on real change /// (the `ui_char`/`ui_unit` discipline). +/// +/// Both are claims about what THIS VM holds, so both sit behind a [`crate::ui_script::VmMemo`] +/// (1290): against a `/reload`'s replacement VM (1291) the memo reads fresh, and the view is +/// re-pushed and its events re-fired for a window the new VM has yet to hear about. #[derive(Resource, Default)] struct InspectFeedState { + vm: crate::ui_script::VmMemo, +} + +/// The per-VM half of [`InspectFeedState`] — the change bases. +#[derive(Default)] +struct InspectFeedMemo { last: Option, last_level: Option, } @@ -176,6 +186,9 @@ fn feed_inspect( let Some(mut script) = script else { return; }; + // Resolved against THIS VM (1290/1291): a `/reload` keeps the latched token, and the fresh + // memo re-pushes the view into the VM that replaced the one which last saw it. + let memo = feed.vm.get(&script); // The reach map: for every popup token that resolves to a live, inspectable player, its squared // distance from us. A token is entered ONLY if it passes the two non-distance refusals vmangos @@ -239,10 +252,10 @@ fn feed_inspect( let Some((token, guid, entity)) = resolved else { // Nothing inspected (or the target isn't streamed): clear the view and empty the booth. - if feed.last.is_some() { + if memo.last.is_some() { script.set_inspect(None); - feed.last = None; - feed.last_level = None; + memo.last = None; + memo.last_level = None; } booth.unit = None; return; @@ -270,9 +283,9 @@ fn feed_inspect( guid, slots, }; - if feed.last.as_ref() != Some(&view) { + if memo.last.as_ref() != Some(&view) { script.set_inspect(Some(view.clone())); - feed.last = Some(view); + memo.last = Some(view); // The ref's InspectPaperDollItemSlotButton_OnEvent listens for exactly this, filtered on // arg1 == the inspected unit. script.fire_event( @@ -281,8 +294,8 @@ fn feed_inspect( ); } let level = store.0.unit_level(); - if feed.last_level != level { - feed.last_level = level; + if memo.last_level != level { + memo.last_level = level; script.fire_event("UNIT_LEVEL", vec![ScriptValue::Str(token)]); } } diff --git a/crates/benilla-app/src/ui_item_text.rs b/crates/benilla-app/src/ui_item_text.rs index 4de84825..28db88d0 100644 --- a/crates/benilla-app/src/ui_item_text.rs +++ b/crates/benilla-app/src/ui_item_text.rs @@ -101,6 +101,17 @@ pub(crate) struct ReadSession { /// frame until ready, and re-using the *same* object closes the reader (the reference's toggle). pub(crate) object_guid: u64, pub(crate) source: ReadSource, + /// What the live VM has been told about this session — keyed on the VM (decisions + /// 1290/1291), because the session is world state (the letter is still open) while the + /// fires are VM state: a `/reload` with a book up replaces the frame tree, and the fresh + /// one needs the `ITEM_TEXT_BEGIN` and repaint the old one already consumed. Before this, + /// the reader never came back after a reload while the host still believed it was open. + told: crate::ui_script::VmMemo, +} + +/// [`ReadSession::told`]'s payload — the per-VM fire latches. +#[derive(Default)] +struct ItemTextTold { /// `ITEM_TEXT_BEGIN` fired (the reference fires it once per open, before the text lands). begun: bool, /// `ITEM_TEXT_READY` fired — the session is fully painted. @@ -160,8 +171,7 @@ impl ItemTextOpen { self.pending = Some(ReadSession { object_guid, source, - begun: false, - ready: false, + told: Default::default(), }); } } @@ -257,7 +267,7 @@ fn feed_item_text( let Some(sess) = open.pending.as_mut() else { return; }; - if sess.ready { + if sess.told.get(&script).ready { return; } let Some(readable) = readable( @@ -281,7 +291,7 @@ fn feed_item_text( return; } - if !sess.begun { + if !sess.told.get(&script).begun { script.set_item_text(Some(ItemTextState { item: readable.title.clone(), creator: None, @@ -291,7 +301,7 @@ fn feed_item_text( material: readable.material.clone(), })); script.fire_event("ITEM_TEXT_BEGIN", vec![]); - sess.begun = true; + sess.told.get(&script).begun = true; } let (creator, text, page, has_next) = match &mut sess.source { @@ -358,7 +368,7 @@ fn feed_item_text( material: readable.material, })); script.fire_event("ITEM_TEXT_READY", vec![]); - sess.ready = true; + sess.told.get(&script).ready = true; } /// `PageTextMaterial.dbc` as a resource (decision 1105) — the reader frame's material basename, @@ -387,7 +397,9 @@ fn drain_item_text( continue; // a letter has no pages; its buttons never show }; if turn_page(visited, delta, |id| pages.pages.get(&id).map(|p| p.next)) { - sess.ready = false; // repaint on the next feed, without re-firing BEGIN + // Repaint on the next feed, without re-firing BEGIN (within this VM; a fresh VM + // re-begins regardless, which is the reload repaint). + sess.told.get(&script).ready = false; } } if script.take_item_text_close() && open.pending.take().is_some() { diff --git a/crates/benilla-app/src/ui_logout.rs b/crates/benilla-app/src/ui_logout.rs index 224a6c25..76d0906d 100644 --- a/crates/benilla-app/src/ui_logout.rs +++ b/crates/benilla-app/src/ui_logout.rs @@ -130,6 +130,7 @@ fn drain_logout( self_guid: Res, commands: Res, mut exit: MessageWriter, + mut reload: ResMut, ) { let Some(mut script) = script else { return; @@ -170,6 +171,9 @@ fn drain_logout( info!("logout: force quit"); exit.write(AppExit::Success); } + // Not this module's business beyond routing: the rebuild itself is + // [`crate::ui_script::run_pending_reload`]'s, at the top of the next frame. + SessionRequest::ReloadUi => reload.0 = true, } } } diff --git a/crates/benilla-app/src/ui_script/keybindings_tests.rs b/crates/benilla-app/src/ui_script/keybindings_tests.rs index b944dae9..91e1f2d2 100644 --- a/crates/benilla-app/src/ui_script/keybindings_tests.rs +++ b/crates/benilla-app/src/ui_script/keybindings_tests.rs @@ -27,7 +27,7 @@ use crate::bindings::commands::SPECS; /// The page's real neighbourhood, in the manifest's own order, with the registry seeded the /// way the app seeds it (`crate::bindings::seed_bindings` — registration before any show). -fn harness() -> UiScript { +pub(crate) fn harness() -> UiScript { let mut s = UiScript::new().unwrap(); let cmds: Vec = SPECS .iter() @@ -78,7 +78,7 @@ fn harness() -> UiScript { } /// Open the options window on the Keybindings page. -fn on_page(s: &mut UiScript) { +pub(crate) fn on_page(s: &mut UiScript) { s.run(r#"ShowUIPanel(OptionsFrame); OptionsFrame_SelectCategory("Keybindings")"#) .unwrap(); assert!(s.errors().is_empty(), "on page: {:?}", s.errors()); diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 9d9206e9..3c0f847b 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -278,6 +278,15 @@ impl Plugin for UiScriptPlugin { end_ui_session, ) .add_systems(Update, shutdown_on_exit) + // A queued `ReloadUI()` runs in `PreUpdate` — one whole frame after the drain that + // queued it (the reference's own deferral, `0x495590`), and BEFORE every `Update` + // system, so no per-VM seed or feed can run against the dying VM in the reload frame + // and then leave the new one unseeded until the next. In `Update` the exclusive + // system would float: the scheduler could place it between `sync_cvars` and + // `save_config`, discarding a dirty CVar edit, or after `seed_bindings`, giving one + // whole frame with an empty binding table. See [`run_pending_reload`]. + .init_resource::() + .add_systems(PreUpdate, run_pending_reload) // `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate` // handler reads this frame's number rather than the previous one's. .add_systems(Update, feed_framerate.before(UiInput)) @@ -607,6 +616,11 @@ pub(crate) fn end_ui_session(world: &mut World) { if let Some(mut script) = world.get_non_send_resource_mut::() { shutdown_ui_state(&mut script, identity.as_ref()); } + // The CVar bridge (decision 1291): the dying VM's table folds into the persist state — after + // the shutdown events above (a `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference + // that lands in an engine-side store that survives), before the VM is replaced. The next + // VM's registration seeds from what this writes ([`crate::cvars`]'s saved base). + crate::cvars::fold_dying_vm_cvars(world); world.insert_resource(AddOnIdentity(None)); // **Everything the host is holding that came OUT of the dying VM goes with it.** A change memo @@ -638,6 +652,52 @@ pub(crate) fn end_ui_session(world: &mut World) { install_boot_vm(world); } +/// A `ReloadUI()` waiting to run — set when [`crate::ui_logout`] drains +/// [`benilla_ui::script::SessionRequest::ReloadUi`], consumed by [`run_pending_reload`] at the +/// top of the next frame's `Update`. +/// +/// A flag rather than an immediate call for the reference's own reason (`ds:0xb4b3f4`, its only +/// writer `0x491380` and its only reader the per-frame callback `0x495590`): the VM that queued +/// the request must not be mid-call when it is destroyed. Our drain already runs outside any VM +/// dispatch, but the flag keeps the whole rebuild at one point in the frame — before the input +/// pass — instead of wherever the drain happens to sit. +#[derive(Resource, Default)] +pub(crate) struct ReloadUiPending(pub(crate) bool); + +/// Run a pending `ReloadUI()`: the reference's teardown/rebuild pair (`0x495664 call 0x490bd0`, +/// `0x495669 call 0x48fbf0`), which for us is [`end_ui_session`] then +/// [`load_ingame_ui_on_world_entry`] — the same two functions the logout/login edges run, called +/// back to back without leaving the world (decision 1291). +/// +/// Everything that makes a login correct makes the reload correct **by construction**: the +/// shutdown tail fires `PLAYER_LEAVING_WORLD`/`PLAYER_LOGOUT` and writes the four files (so a +/// `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it), the +/// rebuild is a real login's load (fresh file scope, saved variables, `VARIABLES_LOADED`, +/// `PLAYER_LOGIN`), and every host memory keyed on the VM's identity ([`VmMemo`], decision 1290) +/// expires with the old session id. `PLAYER_ENTERING_WORLD` refires from [`crate::ui_unit`]'s +/// feed once it notices the new VM, with the self descriptor already present — the reference's +/// own ordering, where the event follows the rebuild. +/// +/// **In-world only.** At the glue there is no in-game UI to rebuild and no identity to load +/// addons under; the reference's own gate (`0x494a50(0xa)`) refuses there too. Dropped with a log +/// line rather than deferred — a reload asked for at the character screen answers nothing. +pub(crate) fn run_pending_reload(world: &mut World) { + if !std::mem::take(&mut world.resource_mut::().0) { + return; + } + let in_world = *world + .resource::>() + .get() + == crate::char_select::ClientState::InWorld; + if !in_world { + info!("ui_script: ReloadUI outside the world — dropped"); + return; + } + info!("ui_script: ReloadUI — ending the UI session and building a new one"); + end_ui_session(world); + load_ingame_ui_on_world_entry(world); +} + /// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather /// than a state edge because a quit from in-world never leaves `InWorld`. fn shutdown_on_exit( @@ -1112,8 +1172,11 @@ mod game_menu_tests; #[cfg(test)] mod macro_tests; +// `pub(crate)` for its `harness`/`on_page` alone: the bindings dispatch tests drive the real +// Keybindings page through the real input systems, and building the page twice would let the two +// copies drift. #[cfg(test)] -mod keybindings_tests; +pub(crate) mod keybindings_tests; #[cfg(test)] mod options_tests; diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index b0df9720..fdd853e1 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -97,10 +97,19 @@ fn booted_world() -> World { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); super::setup_script(&mut world); world } +/// Queue and run a `ReloadUI()` the way the app does: the pending flag, then +/// [`super::run_pending_reload`] — which checks the client state itself, so the test states it. +fn reload(world: &mut World, state: crate::char_select::ClientState) { + world.insert_resource(State::new(state)); + world.resource_mut::().0 = true; + super::run_pending_reload(world); +} + /// One login, driven exactly as the app drives it: the roster carries the pick, then the world-entry /// edge runs. fn log_in_as(world: &mut World, name: &str, guid: u64) { @@ -298,3 +307,171 @@ fn logging_out_leaves_no_in_game_frames_behind() { drop(world); let _ = std::fs::remove_dir_all(&tmp); } + +// ───────────────────────────────── ReloadUI (decision 1291) ───────────────────────────────── + +/// **`ReloadUI()` is a real login run in place** — the reference's teardown/rebuild pair +/// (`0x495664`/`0x495669`), which for us is the same two edge functions the logout/login cycle +/// runs. A fresh VM, a fresh file scope, the same character, and the UI back up — without leaving +/// the world. +#[test] +fn reload_ui_is_a_fresh_login_in_place() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("reload"); + let mut world = booted_world(); + + log_in_as(&mut world, "Onehunter", 1); + let first_session = world + .get_non_send_resource::() + .expect("in-world VM") + .session(); + + reload(&mut world, crate::char_select::ClientState::InWorld); + + assert_eq!( + probe_saw(&world).as_deref(), + Some("Onehunter"), + "the reloaded session ran the addon's file scope again, under the same character" + ); + assert_eq!( + probe_loads(&world), + 1, + "…in a FRESH VM — a reload stacked onto the live state would count 2" + ); + assert_ne!( + world + .get_non_send_resource::() + .expect("in-world VM") + .session(), + first_session, + "the VM identity changed, so every VmMemo about the old session expires (1290)" + ); + assert!( + frame_exists(&world, "PlayerFrame"), + "and the in-game UI is back up" + ); + assert!( + !world.resource::().0, + "the request was consumed" + ); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + +/// **A toggle staged through the API takes effect at the reload** — the whole point of the verb. +/// `DisableAddOn` only marks the live registry; the reload's shutdown tail writes `AddOns.txt` +/// (the reference's own last write before the state dies), and the rebuild reads it back — so the +/// addon is genuinely not loaded, not hidden. +#[test] +fn a_disable_staged_in_the_session_applies_at_the_reload() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("disable"); + let mut world = booted_world(); + + log_in_as(&mut world, "Onehunter", 1); + assert!( + probe_saw(&world).is_some(), + "the probe loaded to begin with" + ); + world + .get_non_send_resource::() + .expect("in-world VM") + .run("DisableAddOn('SwitchProbe')") + .expect("DisableAddOn"); + assert!( + probe_saw(&world).is_some(), + "disabling alone changes nothing in the live session — there is no unload (1197)" + ); + + reload(&mut world, crate::char_select::ClientState::InWorld); + + assert_eq!( + probe_saw(&world), + None, + "after the reload the disabled addon's file scope never ran" + ); + let enable_file = super::addons::enable_state_path(Some(&("Realm".into(), "Onehunter".into()))) + .expect("hermetic enable path"); + let text = std::fs::read_to_string(&enable_file).expect("the teardown wrote AddOns.txt"); + assert!( + text.contains("SwitchProbe: disabled"), + "…because the choice reached disk on the way down: {text}" + ); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + +/// **Saved variables survive the reload** — written by the teardown (after `PLAYER_LOGOUT`), +/// restored by the rebuild after file scope, so the saved value wins over the file-scope default +/// (the byte-verified `AddOn_Load` order, 1128). +#[test] +fn saved_variables_round_trip_through_a_reload() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("saved"); + let mut world = booted_world(); + + log_in_as(&mut world, "Onehunter", 1); + world + .get_non_send_resource::() + .expect("in-world VM") + .run("SwitchProbeDB.mark = 41") + .expect("mutate the saved table"); + + reload(&mut world, crate::char_select::ClientState::InWorld); + + let mark = world + .get_non_send_resource::() + .expect("in-world VM") + .eval::>("return SwitchProbeDB and SwitchProbeDB.mark") + .expect("read back") + .unwrap_or(0); + assert_eq!( + mark, 41, + "the reload wrote the table down and the rebuild restored it OVER the file-scope default" + ); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + +/// A `ReloadUI()` that fires outside the world is dropped, not deferred: at the glue there is no +/// in-game UI to rebuild and no identity to load addons under (the reference's own gate, +/// `0x494a50(0xa)`, refuses there too). +#[test] +fn reload_outside_the_world_is_dropped() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("glue-reload"); + let mut world = booted_world(); + + log_in_as(&mut world, "Onehunter", 1); + super::end_ui_session(&mut world); + + reload(&mut world, crate::char_select::ClientState::CharSelect); + + assert_eq!( + probe_saw(&world), + None, + "no addon loaded — the request was dropped, not run against the glue" + ); + assert!( + !frame_exists(&world, "PlayerFrame"), + "and no in-game UI appeared behind the character screen" + ); + assert!( + !world.resource::().0, + "the stale request is consumed, so it cannot fire on the NEXT login's first frame" + ); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 3e435842..93f7328a 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -85,10 +85,30 @@ pub(crate) struct CombatTextEvent { pub(crate) extra: Option, } -/// The feed's change-tracking memory: what we last told the VM, so we only fire events on transitions. +/// The feed's change-tracking memory: what we last told the VM, plus one server-side log-once. +/// +/// The VM half lives behind a [`crate::ui_script::VmMemo`], **inside the resource** — the same +/// law 1290 wrote for `Local` memos, reached the way a `ResMut` system has to reach it: a memory +/// about what THIS VM was told is unreadable against the next VM, so a `/reload` (1291) — which +/// replaces the VM without despawning the world — re-fires `PLAYER_ENTERING_WORLD` and re-runs +/// every transition diff exactly as a fresh login does. Before this, every one of these fields +/// survived the reload and the new VM never heard the events (the logout path re-armed off the +/// self descriptor despawning, which a reload never does). #[derive(Resource, Default)] struct UnitFeedState { - /// Whether `PLAYER_ENTERING_WORLD` has been fired (once at startup). + /// What we last told the VM — dies with the VM it was told to. + vm: crate::ui_script::VmMemo, + /// Whether we have already warned that our own faction template names no side (decision 0657). + /// Re-arms when a side resolves again, so a `.gm on` / `.gm off` cycle logs once each way. + /// **Server memory, not VM memory** — deliberately outside the memo: a `/reload` must not + /// re-log the GM-mode warning. + warned_sideless: bool, +} + +/// The per-VM half of [`UnitFeedState`] — the event-trigger diffs. +#[derive(Default)] +struct UnitFeedMemo { + /// Whether `PLAYER_ENTERING_WORLD` has been fired (once per world entry, once per VM). entered_world: bool, /// Per token, the last snapshot we pushed — the per-field event triggers diff against it. last: HashMap, @@ -118,9 +138,6 @@ struct UnitFeedState { /// (decision 0652). `None` until first seen: the reference reacts to a *changed*-bits mask, so /// the descriptor that first carries the flag at login announces nothing. pvp_desired: Option, - /// Whether we have already warned that our own faction template names no side (decision 0657). - /// Re-arms when a side resolves again, so a `.gm on` / `.gm off` cycle logs once each way. - warned_sideless: bool, } /// Adds the per-frame unit feed. The `Unit*` bindings themselves live in `benilla-ui`; this only @@ -583,7 +600,7 @@ pub(crate) fn enrich_unit( /// caller the reference has (`SlashCmdList["PVP"]`), and the only one we have. /// /// This rides the unit feed's plugin rather than a `ui_pvp` of its own: the family's whole client -/// state is one remembered bit ([`UnitFeedState::pvp_desired`]), which lives with the feed's other +/// state is one remembered bit ([`UnitFeedMemo::pvp_desired`]), which lives with the feed's other /// self-flag edge (`in_combat`) rather than in a plugin of its own. fn drain_pvp_toggles(script: Option>, commands: Res) { let Some(mut script) = script else { @@ -794,6 +811,10 @@ fn feed_units( } feed.warned_sideless = sideless; } + // The VM half — resolved against THIS VM, so a `/reload`'s fresh state reads a fresh memo and + // every event below re-fires for it (1291). Taken after the `warned_sideless` writes above: + // that field is server memory and must NOT expire with the VM. + let memo = feed.vm.get(&script); let target = selection.target.zip(selection.guid).and_then(|(e, guid)| { let store = stores.get(e).ok()?; let name = names.resolve(guid, &commands).map(str::to_string); @@ -842,8 +863,8 @@ fn feed_units( if let Some((store, _)) = self_q.iter().next() { let xp = store.0.player_xp().unwrap_or(0); let next = store.0.player_next_level_xp().unwrap_or(0); - if feed.last_xp != Some((xp, next)) { - feed.last_xp = Some((xp, next)); + if memo.last_xp != Some((xp, next)) { + memo.last_xp = Some((xp, next)); script.set_player_xp(xp, next); script.fire_event("PLAYER_XP_UPDATE", vec![]); } @@ -865,9 +886,9 @@ fn feed_units( store.0.player_rest_state_experience().unwrap_or(0), store.0.player_flags(), ); - if feed.last_rest != Some(rest) { - let prev = feed.last_rest; - feed.last_rest = Some(rest); + if memo.last_rest != Some(rest) { + let prev = memo.last_rest; + memo.last_rest = Some(rest); script.set_rest_state(rest.0, rest.1, rest.2 & PLAYER_FLAGS_RESTING != 0); if prev.map(|p| (p.0, p.1)) != Some((rest.0, rest.1)) { script.fire_event("UPDATE_EXHAUSTION", vec![]); @@ -904,7 +925,7 @@ fn feed_units( // args wait for a consumer. if let Some((store, _)) = self_q.iter().next() { if let Some(level) = store.0.unit_level() { - let prev = feed.last_level.replace(level); + let prev = memo.last_level.replace(level); if prev.is_some_and(|p| level != p) { script.fire_event("PLAYER_LEVEL_UP", vec![ScriptValue::Int(i64::from(level))]); } @@ -929,40 +950,40 @@ fn feed_units( // 60→1 char switch latched the max-level rail shown over a level-1 body (1106's live // repro), and a normal→rested char switch would misfire "You feel rested." at login. if self_pair.is_some() { - if !feed.entered_world { + if !memo.entered_world { script.fire_event("PLAYER_ENTERING_WORLD", vec![]); - feed.entered_world = true; + memo.entered_world = true; } - } else if feed.entered_world { - feed.entered_world = false; - feed.last_xp = None; - feed.last_rest = None; - feed.last_level = None; - feed.last_combo = None; - feed.in_combat = None; - feed.pvp_desired = None; + } else if memo.entered_world { + memo.entered_world = false; + memo.last_xp = None; + memo.last_rest = None; + memo.last_level = None; + memo.last_combo = None; + memo.in_combat = None; + memo.pvp_desired = None; } for (token, snap) in [("player", &player), ("target", &target)] { match snap { Some(cur) => { - let prev = feed.last.get(token); + let prev = memo.last.get(token); if prev != Some(cur) { fire_transitions(&mut script, token, prev, cur); - feed.last.insert(token.to_string(), cur.clone()); + memo.last.insert(token.to_string(), cur.clone()); } } None => { // Clearing a token isn't a UNIT_* event; the target frame reacts to // PLAYER_TARGET_CHANGED below. - feed.last.remove(token); + memo.last.remove(token); } } } // PLAYER_TARGET_CHANGED (no args, real WoW's shape) when the selection changes. - if selection.guid != feed.target_guid { - feed.target_guid = selection.guid; + if selection.guid != memo.target_guid { + memo.target_guid = selection.guid; script.fire_event("PLAYER_TARGET_CHANGED", vec![]); } @@ -972,9 +993,9 @@ fn feed_units( // the COMBAT_TEXT_UPDATE emission pin). if let Some((store, _)) = self_pair { let in_combat = store.0.unit_flags() & 0x0008_0000 != 0; - if feed.in_combat != Some(in_combat) { - let first_sight = feed.in_combat.is_none(); - feed.in_combat = Some(in_combat); + if memo.in_combat != Some(in_combat) { + let first_sight = memo.in_combat.is_none(); + memo.in_combat = Some(in_combat); if !first_sight || in_combat { script.fire_event( if in_combat { @@ -997,14 +1018,14 @@ fn feed_units( // guid == localPlayer gate) and only on a change, never on the descriptor that first carries it. if let Some((store, _)) = self_pair { let desired = store.0.player_flags() & PLAYER_FLAGS_PVP_DESIRED != 0; - if let Some((toast, verbose)) = pvp_announcement(feed.pvp_desired, desired) { + if let Some((toast, verbose)) = pvp_announcement(memo.pvp_desired, desired) { script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(toast.to_string())]); chat.push_event(ChatEvent::text_only( ChatEventKind::System, verbose.to_string(), )); } - feed.pvp_desired = Some(desired); + memo.pvp_desired = Some(desired); } // The combo-point feed: `PLAYER_FIELD_BYTES` byte 1 and the `PLAYER_FIELD_COMBO_TARGET` GUID @@ -1026,8 +1047,8 @@ fn feed_units( store.0.player_combo_points().unwrap_or(0), store.0.player_combo_target(), ); - if let Some(fire) = combo_edge(feed.last_combo, banked) { - feed.last_combo = Some(banked); + if let Some(fire) = combo_edge(memo.last_combo, banked) { + memo.last_combo = Some(banked); script.set_combo_points(banked.0, banked.1); if fire { script.fire_event("PLAYER_COMBO_POINTS", vec![]); diff --git a/crates/benilla-ui/src/script/cvars.rs b/crates/benilla-ui/src/script/cvars.rs index f0a8e043..39950232 100644 --- a/crates/benilla-ui/src/script/cvars.rs +++ b/crates/benilla-ui/src/script/cvars.rs @@ -3,19 +3,25 @@ //! In the reference every player setting is a CVar: a case-insensitively named string value with //! a registered default, read and written from Lua through `GetCVar`/`SetCVar`/`GetCVarDefault` //! (the 1.12 options panels are thin UI over this table). benilla keeps the same shape at the -//! same seam: the **host registers** the vars it actually backs with engine knobs -//! ([`super::UiScript::register_cvars`] — the honest-tree rule: no key without a knob), Lua -//! reads/writes them synchronously, and each Lua write lands on the **change queue** the host +//! same seam: the **host registers** the vars it implements +//! ([`super::UiScript::register_cvars`] — most backed by an engine knob, a few consumed by Lua +//! alone), Lua reads/writes them synchronously, and each Lua write lands on the **change queue** the host //! drains per frame ([`super::UiScript::take_cvar_changes`]) to sync its resources and mark the //! config file dirty. Host-side writes ([`super::UiScript::set_cvar_host`] — the loaded config, //! an env override) do NOT ride the queue: the host already knows, and an echo would re-dirty //! the file it just loaded. //! //! Values are **strings**, like the client's (`GetCVar` returns a string; consumers parse and -//! clamp at their own edge). An unknown name warns once and no-ops — every benilla CVar is -//! host-registered, so an unknown key is a typo or an unshipped feature, never a storage slot. -//! Divergence, disclosed: the client also surfaces `RegisterCVar` to Lua; nothing in our shipped -//! XML calls it, so it is not modeled until something does. +//! clamp at their own edge). An unknown name warns once and no-ops — a benilla CVar is either +//! host-registered or addon-declared through `RegisterCVar` (decision 1195), so an unknown key +//! is a typo or an unshipped feature, never a storage slot. +//! +//! **The table dies with the VM, so persistence bridges it** (decision 1291): in the reference +//! this store is engine memory and survives every `ReloadUI`; ours is per-VM state, replaced at +//! every login and reload (1290/1291). The host hands each fresh VM the config file's values +//! ([`super::UiScript::set_cvar_saved_base`]) before anything registers, and registration — +//! either kind — starts a key at its saved value. The host folds the dying VM's table back into +//! its persist state on the session edge, so the two halves meet. use mlua::{Lua, Value}; @@ -33,8 +39,26 @@ pub(crate) struct CvarSlot { } impl super::UiScript { + /// Hand registration the config file's persisted values (decision 1291): name → value, keys + /// lowercased here. Set **before** any `register_cvars` / addon `RegisterCVar` runs in this + /// VM; a name registered while present here starts at the saved value instead of its default. + /// + /// This is the bridge that makes the per-VM table behave like the reference's engine-side + /// one (where the store outlives every `ReloadUI`): a CVar with no host knob + /// (`statusBarText`) and a CVar only an addon declares would otherwise revert to default on + /// every VM replacement — and the saver, seeing value == default, would then *strip the + /// player's setting from the file*. + pub fn set_cvar_saved_base(&mut self, entries: impl IntoIterator) { + self.model_mut().cvars_saved_base = entries + .into_iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v)) + .collect(); + } + /// Register the host-backed CVars (name, default) — boot-time, idempotent. A re-register of - /// a live table refreshes defaults but never clobbers a value someone already set. + /// a live table refreshes defaults but never clobbers a value someone already set. A fresh + /// registration starts at the saved-base value when the config file carries one + /// ([`Self::set_cvar_saved_base`]), else at the default. pub fn register_cvars<'a>(&mut self, vars: impl IntoIterator) { let mut model = self.model_mut(); for (name, default) in vars { @@ -42,11 +66,16 @@ impl super::UiScript { match model.cvars.get_mut(&key) { Some(slot) => slot.default = default.to_string(), None => { + let value = model + .cvars_saved_base + .get(&key) + .cloned() + .unwrap_or_else(|| default.to_string()); model.cvars.insert( key, CvarSlot { name: name.to_string(), - value: default.to_string(), + value, default: default.to_string(), }, ); @@ -153,9 +182,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let value = value.as_ref().and_then(value_to_string).unwrap_or_default(); let mut model = lua.app_data_mut::().expect("model app_data"); let key = name.to_ascii_lowercase(); + // The saved base outranks the declared default (decision 1291): an addon-declared + // CVar the player has set persists in the config file, and this lookup is how the + // 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: value.clone(), + value: saved.unwrap_or_else(|| value.clone()), default: value, }); Ok(()) @@ -353,6 +387,55 @@ mod tests { /// /// It is a real 1.12 CVar (`0x83f2d0`, persisted — the client builds its SavedVariables path /// from it) and it had no value here at all. `Ace/AceState.lua:27` is + /// **Registration honors the saved base** (decision 1291) — the bridge that makes the + /// per-VM table behave like the reference's engine-side store: a knobless CVar keeps the + /// player's persisted value across a VM replacement, and the default stays the DEFAULT so + /// "moved off default" still means something to the saver. + #[test] + fn registration_starts_at_the_saved_value_not_the_default() { + let mut s = UiScript::new().unwrap(); + s.set_cvar_saved_base([("StatusBarText".to_string(), "1".to_string())]); + s.register_cvars([("statusBarText", "0"), ("farclip", "500")]); + + assert_eq!( + s.eval::(r#"return GetCVar("statusBarText")"#) + .unwrap(), + "1", + "a saved value outranks the registered default (any key case)" + ); + assert_eq!( + s.eval::(r#"return GetCVarDefault("statusBarText")"#) + .unwrap(), + "0", + "…while the default stays the default" + ); + assert_eq!( + s.eval::(r#"return GetCVar("farclip")"#).unwrap(), + "500", + "a key the file never carried starts at its default" + ); + } + + /// The same law through the Lua half: an addon's `RegisterCVar` (decision 1195) starts at + /// the persisted value, so its setting survives the VM being replaced (1290/1291). + #[test] + fn an_addon_registered_cvar_starts_at_its_saved_value() { + let mut s = UiScript::new().unwrap(); + s.set_cvar_saved_base([("myaddon_scale".to_string(), "2.5".to_string())]); + s.run(r#"RegisterCVar("MyAddon_Scale", "1.0")"#).unwrap(); + assert_eq!( + s.eval::(r#"return GetCVar("MyAddon_Scale")"#) + .unwrap(), + "2.5" + ); + assert_eq!( + s.eval::(r#"return GetCVarDefault("MyAddon_Scale")"#) + .unwrap(), + "1.0", + "the declared value is the DEFAULT, not the live value" + ); + } + /// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which EVERY Ace addon runs at /// PLAYER_ENTERING_WORLD, so nil became `gsub(nil, ...)` and took the whole family down. /// diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index f0e520e0..e1536eec 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -302,6 +302,13 @@ pub(crate) struct Model { /// The CVar table (decision 0954, [`super::cvars`]): lowercase name → slot. Host-registered /// only; Lua reads/writes through `GetCVar`/`SetCVar`/`GetCVarDefault`. pub(crate) cvars: HashMap, + /// The persisted values registration honors (decision 1291): lowercase name → the config + /// file's value, set by the host **before** any registration. A CVar registered while its + /// name is in here — host-registered or an addon's `RegisterCVar` — starts at the saved + /// value, not the default. This is what makes a knobless CVar (`statusBarText`) and an + /// addon-declared one survive the VM being replaced: in the reference the table is engine + /// memory and outlives every `ReloadUI`; ours is per-VM, so the file value is the bridge. + pub(crate) cvars_saved_base: HashMap, /// `(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)>, @@ -1075,6 +1082,7 @@ impl Model { pvp_toggles: 0, sound_queue: Vec::new(), cvars: HashMap::new(), + cvars_saved_base: HashMap::new(), cvar_changes: Vec::new(), cvars_warned: HashSet::new(), saved_names: Vec::new(), diff --git a/crates/benilla-ui/src/script/session.rs b/crates/benilla-ui/src/script/session.rs index 81b929c1..7ff7b641 100644 --- a/crates/benilla-ui/src/script/session.rs +++ b/crates/benilla-ui/src/script/session.rs @@ -1,5 +1,5 @@ -//! The **session-exit API** (decision 0674) — the four globals the game menu's Logout and Exit -//! Game buttons call, and the two dialogs' answers to them. +//! The **session API** (decisions 0674, 1291) — the four exit globals the game menu's Logout and +//! Exit Game buttons call, the two dialogs' answers to them, and `ReloadUI()`. //! //! The shape is [`super::duel`]'s: nothing to read, only the outbound half. Each call queues a //! [`SessionRequest`] the app drains ([`super::UiScript::take_session_requests`]) and turns into a @@ -38,6 +38,15 @@ pub enum SessionRequest { CancelLogout, /// `ForceQuit()` — end the process now, no server round trip. ForceQuit, + /// `ReloadUI()` — tear this VM down and build a fresh one, without leaving the world. + /// + /// The reference's `ReloadUI 0x4884d0` reads no arguments and returns no values; it only sets + /// `ds:0xb4b3f4`, and the per-frame callback `0x495590` runs the teardown/rebuild pair + /// (`0x490bd0` → `0x48fbf0`) on the NEXT frame — a deferral that doubles as the reentrancy + /// guard: the Lua state being destroyed is never the one mid-way through executing the call. + /// Queuing an intent the app runs outside any VM call is that same guard in this engine's + /// shape (wow-re `system/ui/scratch/savedvariables-protocol.md` §2, byte-verified). + ReloadUi, } impl super::UiScript { @@ -54,7 +63,7 @@ impl super::UiScript { } } -/// Register the four session-exit globals. +/// Register the five session globals — the four exit verbs, and `ReloadUI`. pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); @@ -63,6 +72,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { ("Quit", SessionRequest::Quit), ("CancelLogout", SessionRequest::CancelLogout), ("ForceQuit", SessionRequest::ForceQuit), + ("ReloadUI", SessionRequest::ReloadUi), ] { g.set( name, diff --git a/crates/benilla-world/src/doodad_anim/mod.rs b/crates/benilla-world/src/doodad_anim/mod.rs index d2d9842f..19e1b9a6 100644 --- a/crates/benilla-world/src/doodad_anim/mod.rs +++ b/crates/benilla-world/src/doodad_anim/mod.rs @@ -407,6 +407,9 @@ fn gate_doodad_anim( radius, center, instance: None, + // …and no rooms either, for the same reason: a placement's particles-only + // model is not a building's prop, so nothing gates it on a portal PVS. + room: None, }; fade.in_draw_set( cam_pos, @@ -420,6 +423,7 @@ fn gate_doodad_anim( false, ), fade.exterior_admitted(&exterior_gate, camera_instance), + fade.room_admitted(None), // no rooms ⇒ admitted; the single spelling, not `true` ) }) } else { diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index 85d69f23..8c50b684 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -153,6 +153,28 @@ pub(super) const MAX_POINT_LIGHTS: usize = 256; /// (0285: each unit picks its ≤3 nearest from this table) bounded. const POINT_PACK_RADIUS: f32 = 300.0; +/// **The rooms a point light belongs to** — a WMO's own MOLT fixture (the groups whose MOLR names +/// it) or one of its props' M2 lights (the groups whose MODR names the prop). Absent on an ADT map +/// doodad's light, a creature's, a GameObject's: nothing claims those. +/// +/// The fourth rider of decision 0689's law, after the prop's mesh, its particle clouds and its +/// ribbon trails. The reference never needs it: a WMO's furniture is instantiated out of each +/// **visible** group's MODR list, so a torch in a culled room does not exist and registers no +/// light. Its light-register walk really does have no visibility term of its own — byte-verified, +/// wow-re `m2-light-emitter-instances.md` §4: the gate for a model entering the register walk is +/// the scene update-list activation flag `[model+0x10]`, "not visibility, not distance, not LOD", +/// and the ≤4 cap is purely receiver-side. So the faithful fix is NOT a visibility test bolted onto +/// the gather; it is that the SOURCE should not be there at all, which is what this component says. +/// [`build_light_data`] drops the light while its rooms are culled, exactly as the model-visibility +/// authority drops the prop's own submeshes. +/// +/// A newtype rather than a bare [`crate::wmo_portal::WmoGroupVis`] on purpose: that component on a +/// light entity would enlist it in `apply_model_visibility`'s `group_only` query — a `PointLight` +/// carries `Visibility` and `GlobalTransform`, so it matches — making the model-visibility +/// authority a second writer on an entity whose `Visibility` nothing reads (decision 0025). +#[derive(Component)] +pub struct LightRooms(pub(crate) crate::wmo_portal::WmoGroupVis); + /// Main-world resource holding the packed light for this frame; extracted into the render world where /// [`upload_light`] writes it. Rebuilt every frame by [`build_light_data`] (cheap — one std430 pack). #[derive(Resource, Clone, Copy, ExtractResource)] @@ -249,7 +271,9 @@ fn build_light_data( debug: Res, view: Res, cam: Query<&GlobalTransform, With>, - lights_q: Query<(&PointLight, &GlobalTransform)>, + lights_q: Query<(&PointLight, &GlobalTransform, Option<&LightRooms>)>, + // The per-frame portal PVS, for the room term below ([`LightRooms`]). + portals: Query<&crate::wmo_portal::WmoPortalInstance>, mut data: ResMut, time: Res