From 7d826e673a3a1cc5a2997312e4888eb131e51533 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 14 Aug 2026 19:27:39 +0700 Subject: [PATCH] the UI is built per login, and stray beams stay in their rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the in-game UI ends at the character screen and is rebuilt at the next login, so addons that read the character at file scope stop being frozen on whoever logged in first - everything the host remembers about a UI is keyed on that VM's identity — keybinds, macro icons and every feed survive the rebuild instead of silently pushing nothing - a ribbon trail rides the room its model is drawn by: Caverns of Time's ninety energy trails stop burning through 200 yards of rock - the visibility census gains the sky and ribbon lanes it could not see, and the logout leak probe measures after the release instead of accusing every run Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/area.rs | 12 +- crates/benilla-app/src/bindings.rs | 42 ++- .../src/capture/probe_castcancel.rs | 6 +- .../src/capture/probes/live_fps.rs | 16 +- crates/benilla-app/src/char_select/mod.rs | 36 ++- crates/benilla-app/src/cursor.rs | 27 +- crates/benilla-app/src/cvars.rs | 20 +- crates/benilla-app/src/entities/attach/mod.rs | 1 + .../src/entities/equipment/spawn.rs | 1 + .../benilla-app/src/entities/spell_fx/mod.rs | 1 + crates/benilla-app/src/entities/wmo_props.rs | 6 + crates/benilla-app/src/minimap/blips/mod.rs | 3 +- crates/benilla-app/src/minimap/mod.rs | 6 +- crates/benilla-app/src/sound/ui.rs | 3 +- crates/benilla-app/src/ui_action/feed.rs | 18 +- crates/benilla-app/src/ui_action/state.rs | 3 +- .../src/ui_action/targeting/mod.rs | 3 +- crates/benilla-app/src/ui_bank.rs | 6 +- crates/benilla-app/src/ui_char.rs | 30 +- crates/benilla-app/src/ui_craft.rs | 3 +- crates/benilla-app/src/ui_duel.rs | 3 +- crates/benilla-app/src/ui_gossip.rs | 9 +- crates/benilla-app/src/ui_items/feed.rs | 17 +- crates/benilla-app/src/ui_loot.rs | 3 +- crates/benilla-app/src/ui_loot_roll.rs | 5 +- crates/benilla-app/src/ui_macro/mod.rs | 39 ++- crates/benilla-app/src/ui_mail/mod.rs | 9 +- crates/benilla-app/src/ui_merchant.rs | 12 +- crates/benilla-app/src/ui_party/feed.rs | 3 +- crates/benilla-app/src/ui_pet/bar.rs | 3 +- crates/benilla-app/src/ui_pet/unit.rs | 5 +- crates/benilla-app/src/ui_pet_book.rs | 3 +- crates/benilla-app/src/ui_pet_doll.rs | 3 +- crates/benilla-app/src/ui_pet_stats.rs | 3 +- crates/benilla-app/src/ui_quest.rs | 9 +- crates/benilla-app/src/ui_quest_log.rs | 8 +- crates/benilla-app/src/ui_reputation.rs | 11 +- .../benilla-app/src/ui_script/extract/mod.rs | 13 +- crates/benilla-app/src/ui_script/mod.rs | 154 +++++++-- crates/benilla-app/src/ui_script/session.rs | 289 +++++++++++++++++ .../src/ui_script/world_entry_tests.rs | 300 ++++++++++++++++++ crates/benilla-app/src/ui_shapeshift.rs | 3 +- crates/benilla-app/src/ui_social/feed.rs | 3 +- crates/benilla-app/src/ui_spellbook.rs | 3 +- crates/benilla-app/src/ui_talent.rs | 3 +- crates/benilla-app/src/ui_taxi/mod.rs | 9 +- crates/benilla-app/src/ui_tooltip/mod.rs | 9 +- crates/benilla-app/src/ui_trade.rs | 12 +- crates/benilla-app/src/ui_tradeskill.rs | 3 +- crates/benilla-app/src/ui_trainer/mod.rs | 9 +- crates/benilla-app/src/ui_unit.rs | 24 +- crates/benilla-app/src/ui_world_map.rs | 14 +- crates/benilla-ui/src/script/mod.rs | 22 ++ crates/benilla-world/src/ribbons.rs | 123 ++++++- .../src/terrain_stream/spawn/fx.rs | 5 + .../src/terrain_stream/spawn/mod.rs | 9 + crates/benilla-world/src/world_census.rs | 25 ++ 57 files changed, 1245 insertions(+), 175 deletions(-) create mode 100644 crates/benilla-app/src/ui_script/session.rs create mode 100644 crates/benilla-app/src/ui_script/world_entry_tests.rs diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index b3f15232..6145b931 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -83,6 +83,13 @@ fn load_area_table(mut commands: Commands, assets: Option>) { /// What the client's zone-text cache holds — the compare targets of `0x494780` (zone id + the /// two display strings), the minimap line's own cache (`0x494970`), and the indoor dedup pair /// (`[0x868608]` prev-indoor / `[0x86860c]` prev WMO-area row id). +/// +/// Held as a [`crate::ui_script::VmMemo`], which is not a deviation from that BSS model but the +/// completion of it: the cache's whole purpose is deduping what was last *pushed into the VM*, and +/// the VM is built at world entry and destroyed at the character screen (decision 1290). A fresh VM +/// is the client's own fresh BSS — the zeroed cache below is exactly the state the real client is in +/// at that moment, so the first resolve of every login is a NEW_AREA with the globals written, which +/// is what the zone splash and the minimap line need. #[derive(Default)] struct ZoneCache { /// `None` = never resolved (the zeroed BSS cache: the first resolve is a NEW_AREA). @@ -134,11 +141,12 @@ fn feed_zone_events( areas: Option>, factions: Option>, self_store: Query<&ObjectStore, With>, - mut cache: Local, + mut cache: Local>, ) { let (Some(mut script), Some(areas)) = (script, areas) else { return; }; + let cache = cache.get(&script); let Some(leaf) = world.area() else { return }; let Some(leaf_row) = areas.0.get(leaf) else { // An id the catalog doesn't know stays unpublished (keep the last real state, like the @@ -228,7 +236,7 @@ fn feed_zone_events( signal.subzone_text.clone() }; - let event = elect_event(&cache, &signal); + let event = elect_event(cache, &signal); let minimap_changed = cache.minimap_text != minimap_text; if event.is_none() && !minimap_changed { return; diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index 19939ab3..a3c465ba 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -83,7 +83,10 @@ struct BindingDispatch { /// order — re-snapshotted with the map, so the two can never disagree about what an index /// means. Empty in every session with no addon bindings, which is every session today. addons: Vec, - seen_generation: Option, + /// The engine table's generation this map was built from. **Session-keyed** (1290): a fresh VM + /// restarts its counter at 0, so a bare memo could hold a higher number than the live VM will + /// ever reach and gate the rebuild off for the whole session. + seen_generation: crate::ui_script::VmMemo>, } impl BindingDispatch { @@ -171,7 +174,8 @@ impl BindingsState { struct BindingFiles { account: Option, character: Option, - identity: Option<(String, String)>, + /// Whose set 2 is loaded — **in the VM that is live now**. See [`load_character_bindings`]. + identity: crate::ui_script::VmMemo>, } /// Label for this module's systems inside [`crate::ui_script::UiInput`] — the UI key feed is @@ -187,12 +191,15 @@ impl Plugin for BindingsPlugin { app.init_resource::() .init_resource::() .init_resource::() - // PostStartup like the macro catalog: needs the VM, which lands at the Startup - // schedule boundary. - .add_systems(PostStartup, seed_bindings) .add_systems( Update, ( + // Once per **VM**, not once per process (decision 1290): a login builds a + // fresh one, and an unseeded VM has no command registry at all — + // `sync_dispatch` would build an empty map and every keybind in the session + // would be dead. Hence `Update` with a session-keyed claim rather than + // `PostStartup`, ordered ahead of the pair that reads what it registers. + seed_bindings.before(sync_dispatch), (sync_dispatch, latch_and_dispatch) .chain() .in_set(crate::ui_script::UiInput) @@ -221,10 +228,17 @@ pub(crate) fn registry_commands() -> Vec { .collect() } -/// Register the command registry with the engine table and seed the account set from disk — -/// boot-time, before any window opens. -fn seed_bindings(script: Option>, mut files: ResMut) { +/// Register the command registry with the engine table and seed the account set from disk — once +/// per VM, before any window opens. +fn seed_bindings( + script: Option>, + mut files: ResMut, + mut seeded: Local>, +) { let Some(mut script) = script else { return }; + if !seeded.claim(&script) { + return; + } script.register_bindings(®istry_commands()); files.account = crate::local_state::bindings_account_path(); if let Some(overrides) = read_diff(&files.account) { @@ -260,11 +274,13 @@ fn load_character_bindings( let Some(id) = crate::ui_macro::identity(&roster) else { return; }; - if files.identity.as_ref() == Some(&id) { + // Session-keyed (1290): re-entering the world as the SAME character still meets a fresh VM + // with no set 2 in it, so "same identity" is only a reason to skip within one VM. + if files.identity.get(&script).as_ref() == Some(&id) { return; } files.character = crate::local_state::bindings_character_path(&id.0, &id.1); - files.identity = Some(id); + *files.identity.get(&script) = Some(id); match read_diff(&files.character) { Some(overrides) => { script.seed_binding_set(2, Some(store::resolve(&overrides))); @@ -314,10 +330,10 @@ fn save_bindings(script: Option>, files: Res) fn sync_dispatch(script: Option>, mut dispatch: ResMut) { let Some(mut script) = script else { return }; let generation = script.keybinds_generation(); - if dispatch.seen_generation == Some(generation) { + if *dispatch.seen_generation.get(&script) == Some(generation) { return; } - dispatch.seen_generation = Some(generation); + *dispatch.seen_generation.get(&script) = Some(generation); dispatch.map.clear(); // The addon table first: it is what the names the registry does not know resolve into. Before // 1188 phase 4 an unknown name hit the `continue` below and the binding silently never fired @@ -622,7 +638,7 @@ impl BindingDispatch { Self { map, addons: Vec::new(), - seen_generation: None, + seen_generation: crate::ui_script::VmMemo::default(), } } } diff --git a/crates/benilla-app/src/capture/probe_castcancel.rs b/crates/benilla-app/src/capture/probe_castcancel.rs index 7a8a5c00..7a195d04 100644 --- a/crates/benilla-app/src/capture/probe_castcancel.rs +++ b/crates/benilla-app/src/capture/probe_castcancel.rs @@ -53,7 +53,7 @@ fn bar_timeline( time: ProbeClock, script: Option>, self_player: Query<(), With>, - mut last: Local, + mut last: Local>, ) { if self_player.is_empty() { return; @@ -61,6 +61,10 @@ fn bar_timeline( let Some(script) = script else { return; }; + // Session-keyed (1290) even though this is harness code: the phase string is read *out of* the + // VM, so a stale one would swallow the first transition of the next login — the exact event + // this probe exists to timestamp. + let last = last.get(&script); let Ok(state) = script.eval::(BAR_PHASE_CHUNK) else { return; }; diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index 9b0ce627..f410cb6e 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -253,6 +253,20 @@ fn drive_live_fps( (Some(room), Some(w)) => format!(" room={room} windows={w}"), _ => String::new(), }; + // Beside `room=`, because the two answer the same question from opposite ends: + // `room=none` says the camera claims no interior, `sky=` says whether some building's + // PVS is painting its own backdrop over the world anyway. + let sky = seen + .sky + .as_deref() + .map(|s| format!(" sky={s}")) + .unwrap_or_default(); + // The effect stream's other half. `emitters=`/`particles=` above count quad clouds + // only, so a screen full of ribbon trails reads as an empty scene without this. + let ribbons = seen + .ribbons + .map(|(n, d)| format!(" ribbons={n} ribbons_drawn={d}")) + .unwrap_or_default(); let culled = match seen.cull.as_ref() { Some(v) => format!( " cull_windows={} cull_frusta={} cull_tested={} cull_hidden={} \ @@ -275,7 +289,7 @@ fn drive_live_fps( seen.mats, seen.meshes, seen.images, seen.uv_anims, seen.tint_anims, ); println!( - "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present} occluded_frames={}{at_pin}{gate}{culled}", + "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present} occluded_frames={}{at_pin}{gate}{sky}{ribbons}{culled}", v.len(), at(0.50), at(0.95), diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index 44e7b25d..dcea5783 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -386,8 +386,6 @@ fn back_on_disconnect( mut msgs: MessageReader, mut roster: ResMut, mut next: ResMut>, - mut ui_hover: ResMut, - mut ui_keys: ResMut, ) { if !msgs.read().any(|m| m.session_over) { return; @@ -396,25 +394,23 @@ fn back_on_disconnect( // auto-answer the *next* roster — sending the player straight back into the world they were // just thrown out of, without ever seeing the screen. roster.pending_pick = None; - ui_hover.0 = None; - ui_keys.0 = false; next.set(ClientState::Login); } /// A confirmed `/logout` → back to the glue layer, pick cleared (the follow-up roster must be -/// shown, not auto-answered). Also releases the in-world UI's input latches — `feed_ui_input` -/// stops running outside `InWorld`, so whatever it last wrote would otherwise stick. +/// shown, not auto-answered). +/// +/// The in-world UI's input latches are released by [`crate::ui_script::end_ui_session`], on the +/// `OnExit(InWorld)` edge this transition causes: they stick because `feed_ui_input` stops running +/// outside `InWorld`, which is a fact about the edge and not about *why* we left it — so both this +/// path and the disconnect above get it from one place (1290). fn back_on_logout( mut msgs: MessageReader, mut roster: ResMut, mut next: ResMut>, - mut ui_hover: ResMut, - mut ui_keys: ResMut, ) { if msgs.read().next().is_some() { roster.pending_pick = None; - ui_hover.0 = None; - ui_keys.0 = false; next.set(ClientState::CharSelect); } } @@ -512,14 +508,26 @@ fn debug_logout_smoke( *phase = 2; } 2 if *state.get() == ClientState::CharSelect => { - info!( - "logout-smoke: back at character select — {} tiles resident (must be 0)", - streamer.residency().1 - ); + info!("logout-smoke: back at character select — lingering"); (*phase, *mark) = (3, now); } + // **The residency reading belongs HERE, not on arrival.** It used to print on the first + // frame at CharSelect and report "25 tiles resident (must be 0)" every single run — a + // permanent, alarming, wrong number. `release_world` runs ~150 ms later (it hangs off the + // world-live falling edge, not the state edge), so the probe was reading the count before + // the thing it is checking had happened, and reporting the world it had just left. + // + // It cost more than a wrong line: 1291 wrote the reading up as a live contradiction of + // 0777's release-world claim, on the strength of it reproducing identically on an older + // commit — which it did, because an instrument that measures too early does that reliably. + // A number that is always wrong teaches everyone to skip the line (0777's own lesson about + // tripwires nobody trips, from the other end). 3 if now - *mark > 4.0 => match roster.chars.first().map(|c| (c.guid, c.name.clone())) { Some((guid, name)) => { + info!( + "logout-smoke: {} tiles resident after the release (must be 0)", + streamer.residency().1 + ); info!("logout-smoke: re-entering the world as {name}"); send_pick(&mut roster, &pick, guid); (*phase, *mark) = (4, now); diff --git a/crates/benilla-app/src/cursor.rs b/crates/benilla-app/src/cursor.rs index 33ab3410..aca20555 100644 --- a/crates/benilla-app/src/cursor.rs +++ b/crates/benilla-app/src/cursor.rs @@ -180,12 +180,19 @@ fn drive_displayed_cursor( script: Option>, // The base last applied by the UI-entry restore below — `None` while the pointer is over the // world, so re-entering the UI always restores once. - mut last: Local>, + mut last: Local>>, mut displayed: ResMut, ) { use crate::target::{CursorKind, WorldCursor}; use benilla_ui::script::UiCursorMode; + // A VM memo — the restore edge below is armed by `take_cursor_write`, a VM read — but unlike + // every other feed this system still drives the cursor with **no VM at all** (the character + // screen: the VM lives for one login, decision 1290). `get_for` is the shape for exactly that: + // no VM is a session in its own right, so the base restore re-arms once on each side of the + // glue phase instead of every frame inside it. + let last = last.get_for(script.as_deref()); + // **The BASE mode is not a constant** (`0xbe2c4c`, wow-re cursor-system.md §7: *"it is // independently mutable — e.g. a spell-cancel flow parks it at Cast(2)"*), and that is the // piece B208 kept missing. `ResetCursor` restores *the value of this cell*, not a hardcoded @@ -345,7 +352,10 @@ mod other { mut images: ResMut>, mut payload_cursors: ResMut, window: Option>>, - mut current: Local>, + // The key of the cursor we last handed the window — the macOS arm's `last_set` under the + // same name, because it is the same fact: OS state, not memory about the VM, so it is not a + // [`crate::ui_script::VmMemo`] and does not re-seat at a new login (decision 1290's sweep). + mut last_set: Local>, mut decode_failed: Local>, ) { let Some(window) = window else { @@ -353,7 +363,7 @@ mod other { }; let held_icon = script.as_ref().and_then(|s| payload_icon(s)); if let Some(icon) = held_icon { - if current.as_deref() == Some(icon.as_str()) { + if last_set.as_deref() == Some(icon.as_str()) { return; // already showing this icon } if !payload_cursors.0.contains_key(&icon) && !decode_failed.contains(&icon) { @@ -373,7 +383,7 @@ mod other { } if let Some(handle) = payload_cursors.0.get(&icon) { set_custom_cursor(&mut commands, *window, handle.clone()); - *current = Some(icon); + *last_set = Some(icon); return; } // Decode failed (or still pending catalogs): fall through to the mode cursor below. @@ -383,7 +393,7 @@ mod other { return; }; let stem = cursor.0.stem(); - if current.as_deref() == Some(stem.as_str()) { + if last_set.as_deref() == Some(stem.as_str()) { return; } let handle = cursors @@ -394,7 +404,7 @@ mod other { if let Some(handle) = handle { set_custom_cursor(&mut commands, *window, handle.clone()); } - *current = Some(stem); + *last_set = Some(stem); } /// The shared `CustomCursor::Image` insert — hotspot `(0, 0)`: the vanilla mode cursors' active @@ -484,6 +494,11 @@ mod macos { mode: Res, rig: Res, mut focus: MessageReader, + // None of the `Local`s below is a [`crate::ui_script::VmMemo`], deliberately (decision + // 1290's sweep): every one of them remembers something about the **OS** — what we last told + // AppKit, which BLPs failed to decode, whether the pointer rects are off — and the OS keeps + // that state across the VM's death and rebirth, so re-seating them at a new login would + // re-assert a cursor nothing changed. mut was_looking: Local, mut rects_disabled: Local, mut decode_failed: Local>, diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 7de89161..7ef207f8 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -144,8 +144,11 @@ pub(crate) struct CvarPersist { file: BTreeMap, /// Lowercased names whose value came from an env var this session (never saved). env_overridden: HashSet, - /// The engine table has been registered + seeded (once, when the VM first exists). - registered: bool, + /// The engine table has been registered + seeded — **once per VM**, not once per process + /// (decision 1290). A login builds a fresh VM, so the seed has to happen again: an + /// unregistered table answers every `GetCVar` with nil, and [`save_config`] composes + /// `config.toml` out of that same table. + registered: crate::ui_script::VmMemo, /// A change since the last save; `last_change` drives the one-quiet-second debounce. dirty: bool, last_change: Option, @@ -343,7 +346,7 @@ fn sync_cvars( let Some(mut script) = script else { return; }; - if !persist.registered { + if persist.registered.claim(&script) { script.register_cvars(REGISTERED.iter().copied()); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); let session: [(&str, String); 23] = [ @@ -377,7 +380,6 @@ fn sync_cvars( for (name, value) in session { script.set_cvar_host(name, &value); } - persist.registered = true; } // Take the changes BEFORE touching the knobs: constructing `Knobs` deref-muts every knob // resource, which trips Bevy change detection even when nothing is written — and the @@ -462,6 +464,16 @@ fn save_config( return; }; let snapshot = script.cvars_snapshot(); + // **An empty table is not a player who cleared their settings.** The file is composed from the + // VM's live table, so a VM whose table was never registered would compose the player's + // `config.toml` back out *stripped* — a silent, irreversible loss of everything they had set. + // The seed above is what keeps that from happening; this is the floor under it, because the + // failure is one-way and the next regression in that seed must not be able to reach the disk. + if snapshot.is_empty() { + warn!("config: the VM has no registered cvars — refusing to compose the file from nothing"); + persist.dirty = false; // nothing to save, and retrying every frame changes nothing + return; + } let cvars = compose_file(&persist.file, &persist.env_overridden, &snapshot); let body = toml::to_string(&LocalConfig { cvars: cvars.clone(), diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index 7adf453d..7585f63e 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -833,6 +833,7 @@ 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 ); } } diff --git a/crates/benilla-app/src/entities/equipment/spawn.rs b/crates/benilla-app/src/entities/equipment/spawn.rs index 301a6da1..0600b6f9 100644 --- a/crates/benilla-app/src/entities/equipment/spawn.rs +++ b/crates/benilla-app/src/entities/equipment/spawn.rs @@ -665,6 +665,7 @@ 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 ); } debug!( diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs index c92b1438..bdffcaf2 100644 --- a/crates/benilla-app/src/entities/spell_fx/mod.rs +++ b/crates/benilla-app/src/entities/spell_fx/mod.rs @@ -466,6 +466,7 @@ 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 ); } true diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index f4966650..e3bdc060 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -370,6 +370,12 @@ 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.) + None, ) .is_some() { diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs index 46259140..3e92bef3 100644 --- a/crates/benilla-app/src/minimap/blips/mod.rs +++ b/crates/benilla-app/src/minimap/blips/mod.rs @@ -383,11 +383,12 @@ pub(super) fn drive_blip_tooltip( hover: Res, mut names: ResMut, commands: Res, - mut last: Local>, + mut last: Local>>, ) { let Some(mut script) = script else { return; }; + let last = last.get(&script); let show = match &*hover { MinimapBlipHover::None => None, MinimapBlipHover::Landmark(name, at) => Some((name.clone(), *at, false)), diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index 1382287e..6707b115 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -701,9 +701,10 @@ fn emit_minimap( fn feed_minimap_inside( script: Option>, world: benilla_world::world_point::WorldPoint, - mut was_inside: Local>, + mut was_inside: Local>>, ) { let Some(mut script) = script else { return }; + let was_inside = was_inside.get(&script); let inside = world.interior().is_some(); script.set_minimap_inside(inside); if *was_inside != Some(inside) { @@ -720,9 +721,10 @@ fn feed_minimap_inside( fn feed_game_time( script: Option>, time: Res, - mut last: Local>, + mut last: Local>>, ) { let Some(script) = script else { return }; + let last = last.get(&script); let Some(gt) = time.0 else { return }; let minute = gt.minute_of_day(); if *last == Some(minute) { diff --git a/crates/benilla-app/src/sound/ui.rs b/crates/benilla-app/src/sound/ui.rs index b04e9e56..55deb23d 100644 --- a/crates/benilla-app/src/sound/ui.rs +++ b/crates/benilla-app/src/sound/ui.rs @@ -154,7 +154,7 @@ enum CursorGesture { #[allow(clippy::too_many_arguments)] fn play_item_gesture_sounds( script: Option>, - mut prev: Local>, + mut prev: Local>>, mut items: ResMut, displays: Option>, sounds: Option>, @@ -165,6 +165,7 @@ fn play_item_gesture_sounds( net: Res, ) { let Some(script) = script else { return }; + let prev = prev.get(&script); let now = script.cursor_payload(); if *prev == now { return; diff --git a/crates/benilla-app/src/ui_action/feed.rs b/crates/benilla-app/src/ui_action/feed.rs index 3fead072..7852aad0 100644 --- a/crates/benilla-app/src/ui_action/feed.rs +++ b/crates/benilla-app/src/ui_action/feed.rs @@ -52,6 +52,17 @@ pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionM pub(super) struct FeedMemory { pushed: HashMap, bonus_offset: u8, + /// **Has the identity resolve run against the VM this memory is about?** + /// + /// Wrapping the memory in `VmMemo` (1290) makes a login's fresh VM reset `pushed` — but the + /// diff that reads `pushed` lives *inside* the gate below, and every other input to that gate + /// is host-side and survives the VM. `dirty` is false, and both `template_epoch` and + /// `macro_generation` can legitimately match the reset memory's zeros. The bar would then be + /// fed nothing at all, for the whole session. + /// + /// So the gate takes the memory's own freshness as an input. It is not a fourth *reason* to + /// re-resolve; it is the statement that a resolve is only valid for the VM it ran against. + resolved: bool, /// The [`Items::template_epoch`] the last identity resolve ran at — the feed's half of the /// landed-template redisplay (decision 0660). An advance re-resolves, exactly like a bar edit. template_epoch: u64, @@ -74,11 +85,12 @@ pub(super) fn feed_actions( icons: Option>, sub_classes: Option>, commands: Res, - mut memory: Local, + mut memory: Local>, ) { let Some(mut script) = script else { return; }; + let memory = memory.get(&script); // Rejected casts surface as the client's red error line (UI_ERROR_MESSAGE → the errors // frame), resolved through the byte-verified two-layer display ([`cast_fail`]) against the @@ -246,11 +258,13 @@ pub(super) fn feed_actions( // The epoch is the second input, so a landed answer redisplays like the ref's DBCACHECALLBACK. let template_epoch = items.template_epoch(); let macro_generation = script.macros_generation(); - if actions.dirty + if !memory.resolved + || actions.dirty || template_epoch != memory.template_epoch || macro_generation != memory.macro_generation { actions.dirty = false; + memory.resolved = true; memory.template_epoch = template_epoch; memory.macro_generation = macro_generation; // Cloned once per re-resolve, never per frame: the gate above is a `u64` compare. diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index e19ddda4..b4b38c49 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -321,11 +321,12 @@ pub(super) fn feed_action_state( reputations: Res, mut items: ResMut, commands: Res, - mut memory: Local, + mut memory: Local>, ) { let Some(mut script) = script else { return; }; + let memory = memory.get(&script); let now = Instant::now(); // The frame's atomic clock pair — `ui_triple`'s conversion base: every cooldown is pushed as // its absolute start on the GetTime clock, derived through the ONE lawful pair diff --git a/crates/benilla-app/src/ui_action/targeting/mod.rs b/crates/benilla-app/src/ui_action/targeting/mod.rs index 26652325..e53fb1fc 100644 --- a/crates/benilla-app/src/ui_action/targeting/mod.rs +++ b/crates/benilla-app/src/ui_action/targeting/mod.rs @@ -246,10 +246,11 @@ pub(crate) fn cancel_targeting_on_right_press( /// a cancel-and-rearm in one frame still counts as a change. pub(crate) fn feed_targeting_to_vm( targeting: Res, - mut last: Local>, + mut last: Local>>, script: Option>, ) { if let Some(mut script) = script { + let last = last.get(&script); script.set_spell_targeting(targeting.active()); script.set_item_pick_armed(targeting.wants(TargetingWants::Item)); // `SpellCanTargetUnit`'s answer (`0x6e6460`'s unit leg). Derived from the word rather than diff --git a/crates/benilla-app/src/ui_bank.rs b/crates/benilla-app/src/ui_bank.rs index 12b49b88..50214627 100644 --- a/crates/benilla-app/src/ui_bank.rs +++ b/crates/benilla-app/src/ui_bank.rs @@ -133,12 +133,14 @@ fn feed_bank( commands: Res, mut names: ResMut, mut errors: ResMut, - mut last: Local>, - mut last_banker: Local>, + mut last: Local>>, + mut last_banker: Local>>, ) { let Some(mut script) = script else { return; }; + let last = last.get(&script); + let last_banker = last_banker.get(&script); // Purchase refusals surface as the client's red error line. for result in errors.0.drain(..) { if let Some(text) = bank_slot_error_text(result) { diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index c5d6025e..3a859c50 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -96,6 +96,11 @@ impl Plugin for UiCharPlugin { } } +/// The skill block [`watch_skill_ups`] last announced: the self guid it was read off, and that +/// character's `skillId → value` map — the diff base for both the chat lines and the event. +/// `None` = nothing announced yet. +type SkillBlock = Option<(u64, std::collections::HashMap)>; + /// The client-generated skill-up feedback (decision 0437, landed with phase 2): the server sends /// NO skill-up message at all — skills mutate only as `PLAYER_SKILL_INFO` descriptor deltas /// (verified at the vmangos source: no chat send anywhere in `UpdateSkill*`/`SetSkill`) — so the @@ -112,11 +117,19 @@ fn watch_skill_ups( self_store: Query<&ObjectStore, With>, skill_lines: Option>, mut chat: ResMut, - mut prev: Local)>>, + mut prev: Local>, ) { let (Some(guid), Ok(store)) = (self_guid.0, self_store.single()) else { return; }; + // The memo is about the VM — it gates `SKILL_LINES_CHANGED`, the pane's only repaint wire — so + // it re-seeds with the VM (decision 1290). With no VM there is nothing to announce to and + // nothing worth remembering: the diff would only queue chat lines for a VM that does not exist + // yet, which is a stale skill-up replayed at the next login. The next VM re-seeds silently. + let Some(mut script) = script else { + return; + }; + let prev = prev.get(&script); let mut cur = std::collections::HashMap::new(); for i in 0..PLAYER_SKILL_SLOTS { if let Some(s) = store.0.player_skill(i) { @@ -131,16 +144,12 @@ fn watch_skill_ups( if cur.is_empty() { return; } - if let Some(mut script) = script { - script.fire_event("SKILL_LINES_CHANGED", vec![]); - } + script.fire_event("SKILL_LINES_CHANGED", vec![]); *prev = Some((guid, cur)); } Some((prev_guid, _)) if *prev_guid != guid => { // A different character logged in — re-seed, never diff across characters. - if let Some(mut script) = script { - script.fire_event("SKILL_LINES_CHANGED", vec![]); - } + script.fire_event("SKILL_LINES_CHANGED", vec![]); *prev = Some((guid, cur)); } Some((_, prev_map)) => { @@ -178,9 +187,7 @@ fn watch_skill_ups( _ => {} } } - if let Some(mut script) = script { - script.fire_event("SKILL_LINES_CHANGED", vec![]); - } + script.fire_event("SKILL_LINES_CHANGED", vec![]); *prev = Some((guid, cur)); } } @@ -203,11 +210,12 @@ fn feed_skills( script: Option>, self_store: Query<&ObjectStore, With>, skill_lines: Option>, - mut last: Local>, + mut last: Local>>, ) { let Some(mut script) = script else { return; }; + let last = last.get(&script); let (Ok(store), Some(skill_lines)) = (self_store.single(), skill_lines.as_deref()) else { return; }; diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index 7ff25c3b..43097b23 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -143,11 +143,12 @@ fn feed_craft( self_store: Query<&ObjectStore, With>, mut items: ResMut, commands: Res, - mut last: Local>, + mut last: Local>>, ) { let Some(mut script) = script else { return; }; + let last = last.get(&script); let fresh = (|| -> Option { let line = open.line?; let craft_type = open.craft_type; diff --git a/crates/benilla-app/src/ui_duel.rs b/crates/benilla-app/src/ui_duel.rs index 6f4b6334..c0c97ab7 100644 --- a/crates/benilla-app/src/ui_duel.rs +++ b/crates/benilla-app/src/ui_duel.rs @@ -228,11 +228,12 @@ fn feed_duel( mut duel: ResMut, mut names: ResMut, commands: Res, - mut fed: Local, + mut fed: Local>, ) { let Some(mut script) = script else { return; }; + let fed = fed.get(&script); // DUEL_REQUESTED(challengerName) — held until the name resolves (module doc, deviation 2). if let Some(guid) = duel.challenger { diff --git a/crates/benilla-app/src/ui_gossip.rs b/crates/benilla-app/src/ui_gossip.rs index 56971183..afa058bd 100644 --- a/crates/benilla-app/src/ui_gossip.rs +++ b/crates/benilla-app/src/ui_gossip.rs @@ -189,13 +189,16 @@ fn feed_gossip( mut names: ResMut, commands: Res, states: Res, - mut last: Local>, - mut last_name: Local>, - mut last_npc: Local>, + mut last: Local>>, + mut last_name: Local>>, + mut last_npc: Local>>, ) { let Some(mut script) = script else { return; }; + let last = last.get(&script); + let last_name = last_name.get(&script); + let last_npc = last_npc.get(&script); let mut fresh = snapshot(&state); // Expand the greeting's chat-text macros ($N/$B/$G/$w) client-side, as the real client does. if let Some(greeting) = fresh.as_mut().and_then(|m| m.greeting.as_mut()) { diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index 1898ac57..55549375 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -216,11 +216,14 @@ pub(super) fn feed_item_sets( commands: Res, spells: Option>, skill_lines: Option>, - mut pending: Local>, + mut pending: Local< + crate::ui_script::VmMemo>, + >, ) { let Some(mut script) = script else { return; }; + let pending = pending.get(&script); for id in script.take_item_set_asks() { pending.entry(id).or_default(); } @@ -304,12 +307,14 @@ pub(super) fn feed_item_stats( // ItemDisplayInfo icons the bag slots already resolve through. classes: Option>, icons: Option>, - mut pending: Local>, - mut last_home: Local>, + mut pending: Local>>, + mut last_home: Local>>, ) { let Some(mut script) = script else { return; }; + let pending = pending.get(&script); + let last_home = last_home.get(&script); // **`GetBindLocation()`'s push, and it happens BEFORE the early return below.** // // The name resolution lives here because this system already owns it for the hearthstone's @@ -375,11 +380,12 @@ pub(super) fn feed_player_req( factions: Option>, actions: Res, spells: Option>, - mut last: Local>, + mut last: Local>>, ) { let Some(mut script) = script else { return; }; + let last = last.get(&script); let Some(store) = self_q.iter().next() else { return; }; @@ -614,11 +620,12 @@ pub(super) fn feed_containers( mut lock_cleared: ResMut, mut names: ResMut, clock: Res, - mut memory: Local, + mut memory: Local>, ) { let Some(mut script) = script else { return; }; + let memory = memory.get(&script); // The frame's atomic clock pair (`crate::ui_script::UiClock`): the slot resolves read the // cooldown store and convert triples through ONE coherent instant, so a running cooldown's // pushed start is frame-stable (the resource's own doc). diff --git a/crates/benilla-app/src/ui_loot.rs b/crates/benilla-app/src/ui_loot.rs index 572e6975..b55d93db 100644 --- a/crates/benilla-app/src/ui_loot.rs +++ b/crates/benilla-app/src/ui_loot.rs @@ -586,7 +586,7 @@ fn feed_loot( commands: Res, mut errors: ResMut, mut chat: ResMut, - mut last: Local>, + mut last: Local>>, cfg: Res, keys: Res>, mut pickup: MessageWriter, @@ -594,6 +594,7 @@ fn feed_loot( let Some(mut script) = script else { return; }; + let last = last.get(&script); // Loot refusals + "You receive …" lines migrate to the chat window (decision 0084's chat arc): // refusals as informational SYSTEM-yellow lines, receive lines as LOOT-green. The ErrorsFrame // keeps only the cast/equip red toasts. diff --git a/crates/benilla-app/src/ui_loot_roll.rs b/crates/benilla-app/src/ui_loot_roll.rs index 4d469efc..095c7804 100644 --- a/crates/benilla-app/src/ui_loot_roll.rs +++ b/crates/benilla-app/src/ui_loot_roll.rs @@ -448,13 +448,14 @@ fn feed_loot_rolls( self_guid: Res, mut chat: ResMut, time: Res