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