the UI is built per login, and stray beams stay in their rooms
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,13 @@ fn load_area_table(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
/// 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<Res<AreaTableRes>>,
|
||||
factions: Option<Res<Factions>>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut cache: Local<ZoneCache>,
|
||||
mut cache: Local<crate::ui_script::VmMemo<ZoneCache>>,
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -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<AddonBindingBody>,
|
||||
seen_generation: Option<u64>,
|
||||
/// 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<Option<u64>>,
|
||||
}
|
||||
|
||||
impl BindingDispatch {
|
||||
@@ -171,7 +174,8 @@ impl BindingsState {
|
||||
struct BindingFiles {
|
||||
account: Option<std::path::PathBuf>,
|
||||
character: Option<std::path::PathBuf>,
|
||||
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<Option<(String, String)>>,
|
||||
}
|
||||
|
||||
/// 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::<BindingDispatch>()
|
||||
.init_resource::<BindingsState>()
|
||||
.init_resource::<BindingFiles>()
|
||||
// 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<KeybindCommand> {
|
||||
.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<NonSendMut<UiScript>>, mut files: ResMut<BindingFiles>) {
|
||||
/// 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<NonSendMut<UiScript>>,
|
||||
mut files: ResMut<BindingFiles>,
|
||||
mut seeded: Local<crate::ui_script::VmMemo<bool>>,
|
||||
) {
|
||||
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<NonSendMut<UiScript>>, files: Res<BindingFiles>)
|
||||
fn sync_dispatch(script: Option<NonSendMut<UiScript>>, mut dispatch: ResMut<BindingDispatch>) {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ fn bar_timeline(
|
||||
time: ProbeClock,
|
||||
script: Option<NonSend<benilla_ui::script::UiScript>>,
|
||||
self_player: Query<(), With<SelfPlayer>>,
|
||||
mut last: Local<String>,
|
||||
mut last: Local<crate::ui_script::VmMemo<String>>,
|
||||
) {
|
||||
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::<String>(BAR_PHASE_CHUNK) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -386,8 +386,6 @@ fn back_on_disconnect(
|
||||
mut msgs: MessageReader<crate::net::DisconnectedMessage>,
|
||||
mut roster: ResMut<Roster>,
|
||||
mut next: ResMut<NextState<ClientState>>,
|
||||
mut ui_hover: ResMut<crate::ui_script::PlayerUiHover>,
|
||||
mut ui_keys: ResMut<crate::ui_script::UiKeyboardCapture>,
|
||||
) {
|
||||
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<LoggedOutMessage>,
|
||||
mut roster: ResMut<Roster>,
|
||||
mut next: ResMut<NextState<ClientState>>,
|
||||
mut ui_hover: ResMut<crate::ui_script::PlayerUiHover>,
|
||||
mut ui_keys: ResMut<crate::ui_script::UiKeyboardCapture>,
|
||||
) {
|
||||
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);
|
||||
|
||||
@@ -180,12 +180,19 @@ fn drive_displayed_cursor(
|
||||
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
|
||||
// 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<Option<crate::target::WorldCursor>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<crate::target::WorldCursor>>>,
|
||||
mut displayed: ResMut<DisplayedCursor>,
|
||||
) {
|
||||
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<Assets<Image>>,
|
||||
mut payload_cursors: ResMut<PayloadCursorImages>,
|
||||
window: Option<Single<Entity, With<PrimaryWindow>>>,
|
||||
mut current: Local<Option<String>>,
|
||||
// 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<Option<String>>,
|
||||
mut decode_failed: Local<HashSet<String>>,
|
||||
) {
|
||||
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<super::DisplayedCursor>,
|
||||
rig: Res<CameraControl>,
|
||||
mut focus: MessageReader<bevy::window::WindowFocused>,
|
||||
// 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<bool>,
|
||||
mut rects_disabled: Local<bool>,
|
||||
mut decode_failed: Local<HashSet<String>>,
|
||||
|
||||
@@ -144,8 +144,11 @@ pub(crate) struct CvarPersist {
|
||||
file: BTreeMap<String, String>,
|
||||
/// Lowercased names whose value came from an env var this session (never saved).
|
||||
env_overridden: HashSet<String>,
|
||||
/// 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<bool>,
|
||||
/// A change since the last save; `last_change` drives the one-quiet-second debounce.
|
||||
dirty: bool,
|
||||
last_change: Option<Instant>,
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -383,11 +383,12 @@ pub(super) fn drive_blip_tooltip(
|
||||
hover: Res<MinimapBlipHover>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut last: Local<Option<(String, Vec2)>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<(String, Vec2)>>>,
|
||||
) {
|
||||
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)),
|
||||
|
||||
@@ -701,9 +701,10 @@ fn emit_minimap(
|
||||
fn feed_minimap_inside(
|
||||
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
|
||||
world: benilla_world::world_point::WorldPoint,
|
||||
mut was_inside: Local<Option<bool>>,
|
||||
mut was_inside: Local<crate::ui_script::VmMemo<Option<bool>>>,
|
||||
) {
|
||||
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<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
|
||||
time: Res<crate::net::ServerTime>,
|
||||
mut last: Local<Option<u32>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
|
||||
) {
|
||||
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) {
|
||||
|
||||
@@ -154,7 +154,7 @@ enum CursorGesture {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn play_item_gesture_sounds(
|
||||
script: Option<NonSend<UiScript>>,
|
||||
mut prev: Local<Option<CursorPayload>>,
|
||||
mut prev: Local<crate::ui_script::VmMemo<Option<CursorPayload>>>,
|
||||
mut items: ResMut<Items>,
|
||||
displays: Option<Res<ItemDisplays>>,
|
||||
sounds: Option<Res<ItemSounds>>,
|
||||
@@ -165,6 +165,7 @@ fn play_item_gesture_sounds(
|
||||
net: Res<NetCommands>,
|
||||
) {
|
||||
let Some(script) = script else { return };
|
||||
let prev = prev.get(&script);
|
||||
let now = script.cursor_payload();
|
||||
if *prev == now {
|
||||
return;
|
||||
|
||||
@@ -52,6 +52,17 @@ pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionM
|
||||
pub(super) struct FeedMemory {
|
||||
pushed: HashMap<u32, ActionSlot>,
|
||||
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<Res<ItemDisplays>>,
|
||||
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<FeedMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||||
) {
|
||||
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.
|
||||
|
||||
@@ -321,11 +321,12 @@ pub(super) fn feed_action_state(
|
||||
reputations: Res<crate::net::Reputations>,
|
||||
mut items: ResMut<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<StateMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<StateMemory>>,
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -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<SpellTargeting>,
|
||||
mut last: Local<Option<u32>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -133,12 +133,14 @@ fn feed_bank(
|
||||
commands: Res<NetCommands>,
|
||||
mut names: ResMut<NameCache>,
|
||||
mut errors: ResMut<BankErrors>,
|
||||
mut last: Local<Option<BankState>>,
|
||||
mut last_banker: Local<Option<u64>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<BankState>>>,
|
||||
mut last_banker: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
) {
|
||||
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) {
|
||||
|
||||
@@ -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<u16, u16>)>;
|
||||
|
||||
/// 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<SelfPlayer>>,
|
||||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||||
mut chat: ResMut<crate::ui_chat::ChatLog>,
|
||||
mut prev: Local<Option<(u64, std::collections::HashMap<u16, u16>)>>,
|
||||
mut prev: Local<crate::ui_script::VmMemo<SkillBlock>>,
|
||||
) {
|
||||
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<NonSendMut<UiScript>>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||||
mut last: Local<Option<SkillsState>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<SkillsState>>>,
|
||||
) {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -143,11 +143,12 @@ fn feed_craft(
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut items: ResMut<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
mut last: Local<Option<CraftState>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<CraftState>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let fresh = (|| -> Option<CraftState> {
|
||||
let line = open.line?;
|
||||
let craft_type = open.craft_type;
|
||||
|
||||
@@ -228,11 +228,12 @@ fn feed_duel(
|
||||
mut duel: ResMut<DuelState>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut fed: Local<FedDuel>,
|
||||
mut fed: Local<crate::ui_script::VmMemo<FedDuel>>,
|
||||
) {
|
||||
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 {
|
||||
|
||||
@@ -189,13 +189,16 @@ fn feed_gossip(
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
mut last: Local<Option<GossipMenu>>,
|
||||
mut last_name: Local<Option<String>>,
|
||||
mut last_npc: Local<Option<u64>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<GossipMenu>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut last_npc: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
) {
|
||||
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/$<n>w) client-side, as the real client does.
|
||||
if let Some(greeting) = fresh.as_mut().and_then(|m| m.greeting.as_mut()) {
|
||||
|
||||
@@ -216,11 +216,14 @@ pub(super) fn feed_item_sets(
|
||||
commands: Res<NetCommands>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||||
mut pending: Local<std::collections::HashMap<u32, benilla_ui::script::ItemSetView>>,
|
||||
mut pending: Local<
|
||||
crate::ui_script::VmMemo<std::collections::HashMap<u32, benilla_ui::script::ItemSetView>>,
|
||||
>,
|
||||
) {
|
||||
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<Res<super::ItemClasses>>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
mut pending: Local<std::collections::HashSet<u32>>,
|
||||
mut last_home: Local<Option<String>>,
|
||||
mut pending: Local<crate::ui_script::VmMemo<std::collections::HashSet<u32>>>,
|
||||
mut last_home: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
) {
|
||||
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<Res<crate::target::Factions>>,
|
||||
actions: Res<crate::ui_action::PlayerActions>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
mut last: Local<Option<benilla_ui::script::PlayerReqState>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<benilla_ui::script::PlayerReqState>>>,
|
||||
) {
|
||||
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<LockClearedByFailure>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
clock: Res<crate::ui_script::UiClock>,
|
||||
mut memory: Local<FeedMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||||
) {
|
||||
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).
|
||||
|
||||
@@ -586,7 +586,7 @@ fn feed_loot(
|
||||
commands: Res<NetCommands>,
|
||||
mut errors: ResMut<LootErrors>,
|
||||
mut chat: ResMut<crate::ui_chat::ChatLog>,
|
||||
mut last: Local<Option<LootSnapshot>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<LootSnapshot>>>,
|
||||
cfg: Res<LootConfig>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut pickup: MessageWriter<crate::sound::LootPickupSound>,
|
||||
@@ -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.
|
||||
|
||||
@@ -448,13 +448,14 @@ fn feed_loot_rolls(
|
||||
self_guid: Res<SelfGuid>,
|
||||
mut chat: ResMut<ChatLog>,
|
||||
time: Res<Time>,
|
||||
mut last: Local<LootRollsState>,
|
||||
mut last: Local<crate::ui_script::VmMemo<LootRollsState>>,
|
||||
) {
|
||||
rolls.tick(time.delta().as_millis() as u32);
|
||||
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
drain_lines(
|
||||
&mut rolls,
|
||||
self_guid.0,
|
||||
@@ -469,7 +470,7 @@ fn feed_loot_rolls(
|
||||
// same `start()` call that queued `opened`, so pushing after would hand every fresh roll an
|
||||
// empty lookup. Same order as ui_loot's window feed, for the same reason.
|
||||
let fresh = snapshot(&rolls, &mut items, icons.as_deref(), &commands);
|
||||
let changed = repainted(&last, &fresh);
|
||||
let changed = repainted(last, &fresh);
|
||||
if fresh != *last {
|
||||
script.set_loot_rolls(fresh.clone());
|
||||
*last = fresh;
|
||||
|
||||
@@ -36,8 +36,9 @@ pub(crate) struct MacroFiles {
|
||||
account: Option<std::path::PathBuf>,
|
||||
character: Option<std::path::PathBuf>,
|
||||
/// The `(realm, character)` the [`Self::character`] path was built for — the reload trigger
|
||||
/// when a `/logout` brings a different character back into the world.
|
||||
identity: Option<(String, String)>,
|
||||
/// when a `/logout` brings a different character back into the world. Session-keyed (1290),
|
||||
/// because the *same* character coming back still meets a fresh VM with an empty macro table.
|
||||
identity: crate::ui_script::VmMemo<Option<(String, String)>>,
|
||||
}
|
||||
|
||||
/// Macro index → the spell it casts — benilla's `[rec+0x564]` (wow-re
|
||||
@@ -54,16 +55,16 @@ impl Plugin for UiMacroPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<MacroFiles>()
|
||||
.init_resource::<MacroBoundSpells>()
|
||||
// `PostStartup`, not `Startup.after(AssetSet::Open)` — the same reason
|
||||
// `crate::ui_chat::commands::build_slash_commands` sits there: this needs BOTH the
|
||||
// patch chain and the VM, and the VM is inserted by another `Startup` system whose
|
||||
// `insert_non_send_resource` only lands at the schedule boundary. Ordered after
|
||||
// `AssetSet::Open` alone, this ran before the VM existed and silently pushed no icon
|
||||
// list at all (caught on a clean capture run: the boot line never appeared).
|
||||
.add_systems(PostStartup, load_icon_catalog)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Once per **VM** (decision 1290), and in `Update` rather than `PostStartup`:
|
||||
// this needs BOTH the patch chain and the VM, and a login builds a fresh VM
|
||||
// whose icon chooser stays empty until it runs again. `PostStartup` was the
|
||||
// old answer to the ordering half alone — after `AssetSet::Open` this ran
|
||||
// before the VM existed and silently pushed no icon list at all — and a
|
||||
// session-keyed claim answers both halves at once.
|
||||
load_icon_catalog,
|
||||
// Before the action feeds read it (they run in `UnitFeed`), so a macro edited
|
||||
// this frame reports its new spell's cooldown the same frame.
|
||||
rebind_macro_spells.before(crate::ui_unit::UnitFeed),
|
||||
@@ -85,10 +86,17 @@ impl Plugin for UiMacroPlugin {
|
||||
/// two prefixes ([`benilla_formats::load_macro_icons`], where the byte citations live). A failed
|
||||
/// load leaves the list empty: the popup then shows no icons and `MacroPopupOkayButton_Update`
|
||||
/// keeps OKAY disabled, which is a visible, diagnosable failure rather than a silent one.
|
||||
fn load_icon_catalog(script: Option<NonSendMut<UiScript>>, assets: Option<Res<WorldAssets>>) {
|
||||
fn load_icon_catalog(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
mut seeded: Local<crate::ui_script::VmMemo<bool>>,
|
||||
) {
|
||||
let (Some(mut script), Some(assets)) = (script, assets) else {
|
||||
return;
|
||||
};
|
||||
if !seeded.claim(&script) {
|
||||
return;
|
||||
}
|
||||
let loaded = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
benilla_formats::load_macro_icons(&mut chain)
|
||||
@@ -124,13 +132,13 @@ fn load_macros(
|
||||
) {
|
||||
let Some(mut script) = script else { return };
|
||||
let Some(id) = identity(&roster) else { return };
|
||||
if files.identity.as_ref() == Some(&id) {
|
||||
return; // already loaded for this character
|
||||
if files.identity.get(&script).as_ref() == Some(&id) {
|
||||
return; // already loaded for this character, into the VM that is live now
|
||||
}
|
||||
let (realm, character) = (&id.0, &id.1);
|
||||
files.account = crate::local_state::macros_account_path();
|
||||
files.character = crate::local_state::macros_character_path(realm, character);
|
||||
files.identity = Some(id.clone());
|
||||
*files.identity.get(&script) = Some(id.clone());
|
||||
|
||||
let read = |path: &Option<std::path::PathBuf>| -> Vec<benilla_ui::script::MacroView> {
|
||||
let Some(path) = path else { return Vec::new() };
|
||||
@@ -194,11 +202,14 @@ fn rebind_macro_spells(
|
||||
actions: Res<crate::ui_action::PlayerActions>,
|
||||
table: Option<Res<crate::ui_chat::commands::SlashCommands>>,
|
||||
mut bound: ResMut<MacroBoundSpells>,
|
||||
mut last_generation: Local<Option<u64>>,
|
||||
mut last_generation: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
) {
|
||||
let (Some(script), Some(table)) = (script, table) else {
|
||||
return;
|
||||
};
|
||||
// Session-keyed (1290): a fresh VM restarts its macro generation at 0, so a bare memo could
|
||||
// hold a *higher* number than the live VM will ever reach and gate this off for the session.
|
||||
let last_generation = last_generation.get(&script);
|
||||
let generation = script.macros_generation();
|
||||
if *last_generation == Some(generation) && !actions.is_changed() {
|
||||
return;
|
||||
|
||||
@@ -393,9 +393,9 @@ fn feed_mail(
|
||||
commands: Res<NetCommands>,
|
||||
mut pending: ResMut<MailPending>,
|
||||
time: Res<Time>,
|
||||
mut last: Local<Option<MailState>>,
|
||||
mut last_mailbox: Local<Option<u64>>,
|
||||
mut last_has_new_mail: Local<bool>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<MailState>>>,
|
||||
mut last_mailbox: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
mut last_has_new_mail: Local<crate::ui_script::VmMemo<bool>>,
|
||||
// The `$`-macro subject for the letter body: the local player, as at every panel seam.
|
||||
self_q: Query<(&crate::net::ObjectStore, &crate::net::Guid), With<crate::net::SelfPlayer>>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
@@ -403,6 +403,9 @@ fn feed_mail(
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_mailbox = last_mailbox.get(&script);
|
||||
let last_has_new_mail = last_has_new_mail.get(&script);
|
||||
|
||||
// Take/return/delete failures surface as the client's red error line (the equip/cast path shape).
|
||||
for text in std::mem::take(&mut mail.errors) {
|
||||
|
||||
@@ -411,14 +411,18 @@ fn feed_merchant(
|
||||
tables: Option<Res<RepairTables>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
mut errors: ResMut<MerchantErrors>,
|
||||
mut last: Local<Option<MerchantState>>,
|
||||
mut last_money: Local<Option<u64>>,
|
||||
mut last_name: Local<Option<String>>,
|
||||
mut last_vendor: Local<Option<u64>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<MerchantState>>>,
|
||||
mut last_money: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut last_vendor: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_money = last_money.get(&script);
|
||||
let last_name = last_name.get(&script);
|
||||
let last_vendor = last_vendor.get(&script);
|
||||
// Refusals surface as the client's red error line (the equip/cast path's exact shape).
|
||||
for refusal in errors.0.drain(..) {
|
||||
let text = match refusal {
|
||||
|
||||
@@ -58,11 +58,12 @@ pub(super) fn feed_party(
|
||||
// the instruments, and naming it from a game module would push it across the world-API wall
|
||||
// (`tests/world_api_wall.rs`, decision 1164) for a value this already answers.
|
||||
here: benilla_world::world_point::WorldPoint,
|
||||
mut fed: Local<FedParty>,
|
||||
mut fed: Local<crate::ui_script::VmMemo<FedParty>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let fed = fed.get(&script);
|
||||
let self_pair = self_q.iter().next();
|
||||
let self_guid = self_pair.map(|(g, _)| g.0);
|
||||
// The party's PvP faction group (decision 0646 §1): our own. A 1.12 party is always one
|
||||
|
||||
@@ -268,11 +268,12 @@ pub(super) fn feed_pet_bar(
|
||||
clock: Res<crate::ui_script::UiClock>,
|
||||
index: Res<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
mut memory: Local<PetBarMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<PetBarMemory>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let memory = memory.get(&script);
|
||||
let now = Instant::now();
|
||||
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
|
||||
let has_bar = bar.has_bar();
|
||||
|
||||
@@ -61,13 +61,14 @@ pub(super) fn feed_pet_unit(
|
||||
pet: PetUnit,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<PetUnitMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<PetUnitMemory>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let memory = memory.get(&script);
|
||||
let pet_guid = bar.spells.pet_guid;
|
||||
watch_name_timestamp(pet_guid, &mut script, &pet, &mut names, &mut memory);
|
||||
watch_name_timestamp(pet_guid, &mut script, &pet, &mut names, memory);
|
||||
// A bar whose unit has not streamed yet (or has left) pushes nothing: `UnitExists("pet")` then
|
||||
// reads false and the frame hides, which is honest — we have the guid but none of the fields
|
||||
// the frame draws.
|
||||
|
||||
@@ -121,11 +121,12 @@ fn feed_pet_book(
|
||||
index: Res<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
clock: Res<crate::ui_script::UiClock>,
|
||||
mut memory: Local<FeedMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let memory = memory.get(&script);
|
||||
// Nothing to resolve a name/icon/passive from yet — try again once Spell.dbc lands. Unlike the
|
||||
// bar (which can still draw its command tokens), a book with no catalog has no slots at all.
|
||||
let Some(spells) = spells.as_deref() else {
|
||||
|
||||
@@ -53,11 +53,12 @@ fn feed_pet_doll(
|
||||
bar: Res<PetBar>,
|
||||
pet: PetUnit,
|
||||
mut booth: ResMut<PetDollBooth>,
|
||||
mut last: Local<Option<UnitCombatStats>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<UnitCombatStats>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
// The pane's rotate buttons own the yaw; the booth mirrors it (the inspect pane's arrangement,
|
||||
// decision 0631 §4). Written every frame, pet or no pet — a stale yaw would snap the model the
|
||||
// moment one is summoned.
|
||||
|
||||
@@ -235,11 +235,12 @@ fn feed_pet_stats(
|
||||
family_tables: Option<Res<PetFamilyTables>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut last: Local<Option<(bool, PetStats)>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<(bool, PetStats)>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let store = pet.store(bar.spells.pet_guid);
|
||||
let family = family_for(store, &mut names, &commands, family_tables.as_deref());
|
||||
let fresh = stats_for(store, self_store.iter().next(), tables.as_deref(), family);
|
||||
|
||||
@@ -436,13 +436,16 @@ fn feed_quest(
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
mut chat: ResMut<ChatLog>,
|
||||
mut last: Local<Option<QuestState>>,
|
||||
mut last_name: Local<Option<String>>,
|
||||
mut last_npc: Local<Option<u64>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<QuestState>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut last_npc: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
) {
|
||||
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);
|
||||
// The turn-in fanfare (QUESTCOMPLETED → iQuestComplete.wav): the client's C++ plays it on the
|
||||
// QUEST_COMPLETE packet itself — no Lua handler owns it, so the feed queues it directly.
|
||||
if giver.take_completed_fanfare() {
|
||||
|
||||
@@ -422,12 +422,14 @@ fn feed_quest_log(
|
||||
commands: Res<NetCommands>,
|
||||
header_names: Option<Res<QuestHeaderNamesRes>>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
mut last: Local<QuestLogState>,
|
||||
mut prior_quest_ids: Local<Option<HashSet<u32>>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<QuestLogState>>,
|
||||
mut prior_quest_ids: Local<crate::ui_script::VmMemo<Option<HashSet<u32>>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let prior_quest_ids = prior_quest_ids.get(&script);
|
||||
let Some((store, _)) = self_q.iter().next() else {
|
||||
// No self player streamed yet — nothing to show; `last` stays at its (empty) default, so
|
||||
// this is a no-op rather than a repeated empty push.
|
||||
@@ -608,7 +610,7 @@ fn feed_quest_log(
|
||||
// minutes when you achieve a quest objective") runs off this, a deliberate
|
||||
// intent-over-letter divergence from the ref's broken chain (QuestLogFrame.xml's
|
||||
// auto-watch comment; decision record with this fold-back).
|
||||
let progressed = quests_with_progressed_objectives(&last, &fresh);
|
||||
let progressed = quests_with_progressed_objectives(last, &fresh);
|
||||
script.set_quest_log(fresh.clone());
|
||||
script.fire_event("QUEST_LOG_UPDATE", vec![]);
|
||||
if !progressed.is_empty() {
|
||||
|
||||
@@ -109,6 +109,11 @@ pub(crate) fn reputation_row(
|
||||
})
|
||||
}
|
||||
|
||||
/// The two cheap inputs [`feed_reputation`]'s gate watches by value — `(race, class, watched
|
||||
/// faction id)`; the other two are resources, watched with Bevy's own change detection. `None` =
|
||||
/// not yet read.
|
||||
type ReputationInputs = Option<(u8, u8, Option<u32>)>;
|
||||
|
||||
/// Push the snapshot when one of its inputs moves, and fire `UPDATE_FACTION` when the result
|
||||
/// actually differs — the whole-snapshot-replace seam [`crate::ui_char::feed_skills`] established,
|
||||
/// with one addition.
|
||||
@@ -129,12 +134,14 @@ fn feed_reputation(
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
reputations: Res<Reputations>,
|
||||
factions: Option<Res<Factions>>,
|
||||
mut last: Local<Option<ReputationState>>,
|
||||
mut last_inputs: Local<Option<(u8, u8, Option<u32>)>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<ReputationState>>>,
|
||||
mut last_inputs: Local<crate::ui_script::VmMemo<ReputationInputs>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_inputs = last_inputs.get(&script);
|
||||
let (Ok(store), Some(factions_res)) = (self_store.single(), factions.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -77,6 +77,11 @@ pub(super) fn drive_script(
|
||||
// The seam scale the engine's text-metric caches were answered under. When `s` moves (window
|
||||
// resize / fullscreen toggle / uiScale change), every cached measure is stale — see the
|
||||
// invalidation below.
|
||||
//
|
||||
// Deliberately NOT a `VmMemo` (decision 1290's sweep): this is a fact about the RASTER, not
|
||||
// about what the VM has been told, and it stays true across the VM's death and rebirth. A fresh
|
||||
// VM has no cached measures to invalidate and no measurer at all, and the re-seat below already
|
||||
// catches that on its own test (`!script.has_text_measurer()`).
|
||||
mut last_seam: Local<f32>,
|
||||
// The uiScale dial folded into the seam scale (decision 0584).
|
||||
ui_scale: Res<super::UiScaleCvar>,
|
||||
@@ -86,7 +91,12 @@ pub(super) fn drive_script(
|
||||
// caches are monotone path→handle, so equal inputs reproduce the same `UiQuads` the diff
|
||||
// would then discard. Capture mode never skips (the harness wants exact per-frame output,
|
||||
// including the cursor-icon quad's live mouse position).
|
||||
mut prev: Local<GateInputs>,
|
||||
//
|
||||
// A [`crate::ui_script::VmMemo`] because a skip is not only a quad-conversion skip: it also skips
|
||||
// `set_link_spans` and the minimap-slot / booth-pane refills, which are pushes INTO the VM. The
|
||||
// VM lives for one login (decision 1290), so a fresh VM must never be gated on what the previous
|
||||
// one extracted — its first frame is always a real conversion.
|
||||
mut prev: Local<crate::ui_script::VmMemo<GateInputs>>,
|
||||
// This frame's phase split, published for whoever asked (the `[ui-cost]` line, `hover_log`).
|
||||
mut ui_cost: ResMut<super::UiFrameCost>,
|
||||
) {
|
||||
@@ -94,6 +104,7 @@ pub(super) fn drive_script(
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let prev = prev.get(&script);
|
||||
let Ok(window) = window.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -38,6 +38,12 @@ mod manifest;
|
||||
/// transcribing it — the rule, the list, and the licensing reason are that module's header.
|
||||
mod reference_ui;
|
||||
|
||||
/// What the host remembers about the VM, and how it forgets — the one mechanism that stops a seed
|
||||
/// or a change-memo outliving the VM it was written against (decision 1290).
|
||||
mod session;
|
||||
|
||||
pub(crate) use session::VmMemo;
|
||||
|
||||
// The manifest's loaders read as `ui_script::…` at every call site, including the tests' `super::`.
|
||||
// `load_default_ui` is no longer test-only: the addon harness (1188 phase 6) loads the whole
|
||||
// shipped interface under each surveyed addon, because roughly half of what an addon calls is
|
||||
@@ -253,7 +259,6 @@ impl Plugin for UiScriptPlugin {
|
||||
.init_resource::<PlayerUiClickConsumed>()
|
||||
.init_resource::<CursorPayloadHeld>()
|
||||
.init_resource::<UiClock>()
|
||||
.init_resource::<IngameUiLoaded>()
|
||||
.init_resource::<AddOnIdentity>()
|
||||
// After `AssetSet::Open` so the patch chain exists at boot: the VM's first load is
|
||||
// the real `GlobalStrings.lua` (the reference's own FrameXML order), which the
|
||||
@@ -270,7 +275,7 @@ impl Plugin for UiScriptPlugin {
|
||||
// has them.
|
||||
.add_systems(
|
||||
OnExit(crate::char_select::ClientState::InWorld),
|
||||
shutdown_on_session_end,
|
||||
end_ui_session,
|
||||
)
|
||||
.add_systems(Update, shutdown_on_exit)
|
||||
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
|
||||
@@ -338,21 +343,44 @@ fn arbitrate_pointer_over_ui(
|
||||
}
|
||||
|
||||
fn setup_script(world: &mut World) {
|
||||
install_boot_vm(world);
|
||||
}
|
||||
|
||||
/// Build a **boot VM** and install it: a Lua state carrying the strings, the emote tokens and the
|
||||
/// font-object registry, and no frames at all.
|
||||
///
|
||||
/// This is the state the client sits at outside the world — the login and character screens — and
|
||||
/// the state a world entry loads the in-game UI *onto* ([`load_ingame_ui_on_world_entry`]). It is
|
||||
/// installed twice over a session: once at `Startup`, and again at every [`end_ui_session`], which
|
||||
/// is what makes a second login a genuine second load rather than a re-entry into the first
|
||||
/// login's Lua state.
|
||||
///
|
||||
/// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first `Update`,
|
||||
/// from `script.font_objects()` — and our native glue screens share that one atlas, so the registry
|
||||
/// has to exist before the login screen or in-game text loses its outlined variants and its
|
||||
/// registry-declared sizes for the whole session. The other 55 files are in-game UI and load at
|
||||
/// world entry ([`load_ingame_ui`]); the reference splits at exactly this seam, with GlueXML
|
||||
/// carrying its own `GlueFonts.xml`.
|
||||
///
|
||||
/// **Since 1290 this runs per login, and that is load-bearing rather than incidental.** The
|
||||
/// reference re-loads `Fonts.xml` on every rebuild and *must*: `0x48fbf0`'s toc loop has no
|
||||
/// already-loaded gate, and both halves of a font object die with the old session — the Lua handle
|
||||
/// with `_G`, and the native `CSimpleFont` registry with the frame-script owner (`0x7839c0`, torn
|
||||
/// down at `0x490c97`). Because [`end_ui_session`] routes every login through here, every session's
|
||||
/// VM gets the registry and every `inherits="GameFontNormal"` in FrameXML resolves. A design that
|
||||
/// dropped the VM and skipped index 0 on the in-game load would fail exactly there — verified in
|
||||
/// wow-re's §5 for this change (1291).
|
||||
fn install_boot_vm(world: &mut World) {
|
||||
let script = match UiScript::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("ui_script: VM init failed: {e}");
|
||||
world.remove_non_send_resource::<UiScript>();
|
||||
return;
|
||||
}
|
||||
};
|
||||
load_global_strings(world, &script);
|
||||
load_emote_tokens(world, &script);
|
||||
// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first
|
||||
// `Update`, from `script.font_objects()` — and our native glue screens share that one atlas, so
|
||||
// the registry has to exist before the login screen or in-game text loses its outlined variants
|
||||
// and its registry-declared sizes for the whole session. The other 55 files are in-game UI and
|
||||
// load at world entry ([`load_ingame_ui`]); the reference splits at exactly this seam, with
|
||||
// GlueXML carrying its own `GlueFonts.xml`.
|
||||
if ui_wanted(world) {
|
||||
// Errors are already logged per file as they happen; the returned list is the test-side
|
||||
// assertion (`shipped_xml_tests`), not a second reporting channel.
|
||||
@@ -368,23 +396,25 @@ fn ui_wanted(world: &World) -> bool {
|
||||
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
|
||||
}
|
||||
|
||||
/// Has the in-game UI been materialized this process? The load is **once per process**, not once
|
||||
/// per world entry: nothing tears the frame tree down on `OnExit(InWorld)` yet, so a re-entry after
|
||||
/// `/logout` must not stack a second copy of every window.
|
||||
/// `OnEnter(InWorld)`: materialize the in-game UI for **this** session.
|
||||
///
|
||||
/// The reference *does* tear down and rebuild (`ClientDestroyGame 0x401ee0` ↔ `0x401570`, and its
|
||||
/// `ReloadUI` is verified end to end doing exactly that). Matching it is the follow-on; this latch
|
||||
/// is what makes the move safe without it, and is the thing to delete when the teardown lands.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct IngameUiLoaded(pub(crate) bool);
|
||||
|
||||
/// `OnEnter(InWorld)`: materialize the in-game UI, once.
|
||||
/// **Once per world entry, not once per process** (decision 1290). The reference builds the whole
|
||||
/// in-game UI at `CGGameUI::Initialize 0x48fbf0` and destroys it again at `0x490bd0` on the way
|
||||
/// out, so every login runs every addon's file scope afresh — and that file scope is where the
|
||||
/// corpus reads the character it is looking at (`local currentPlayer = UnitName("player")`, the
|
||||
/// idiom [`seat_from_roster`] already documents). While this was latched per process, a second
|
||||
/// login kept the first login's captured name: the director's "the selected one is always
|
||||
/// Onewarrior no matter what char I log into", and, worse, its saved variables went to the first
|
||||
/// character's file. [`world_entry_tests`] holds both ends.
|
||||
///
|
||||
/// The load runs onto the fresh boot VM [`end_ui_session`] left behind (or, on the first login,
|
||||
/// `Startup`'s) — so nothing here has to unpick the previous session; there is nothing to unpick.
|
||||
///
|
||||
/// Safe on the state edge only because 1038 moved the initial transition after `PostStartup` — a
|
||||
/// capture boots straight into `InWorld`, so before that this would have run ahead of
|
||||
/// [`benilla_assets::AssetSet::Open`] and loaded against no patch chain.
|
||||
fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
if world.resource::<IngameUiLoaded>().0 || !ui_wanted(world) {
|
||||
if !ui_wanted(world) {
|
||||
return;
|
||||
}
|
||||
let Some(mut script) = world.remove_non_send_resource::<UiScript>() else {
|
||||
@@ -427,7 +457,6 @@ fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
finish_ui_load(&mut script);
|
||||
world.insert_non_send_resource(script);
|
||||
world.insert_resource(AddOnIdentity(identity));
|
||||
world.resource_mut::<IngameUiLoaded>().0 = true;
|
||||
}
|
||||
|
||||
/// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in
|
||||
@@ -525,8 +554,19 @@ pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
|
||||
/// carves as (`system/ui/ui.md`):
|
||||
///
|
||||
/// > `PLAYER_LEAVING_WORLD` (273) → **`PLAYER_LOGOUT`** (271, `0x490c2a`) → `layout-cache.txt` →
|
||||
/// > **the flat saved file** (`0x490c7e`) → **the per-addon files** (`0x490c83`) → `AddOns.txt` →
|
||||
/// > destroy the Lua state
|
||||
/// > **the flat saved file** (`0x490c7e`) → **the per-addon files** (`0x490c83`) → `AddOns.txt`
|
||||
/// > (`0x490c88`) → destroy the frame-script owner (`0x490c97`) → nil all 216 C bindings out of
|
||||
/// > `_G` (`0x490cba` → `0x490ce0`)
|
||||
///
|
||||
/// **The last step is not "destroy the Lua state"** — which is what three of wow-re's own notes
|
||||
/// said, until this client's teardown made the question load-bearing and a §5 cross-check settled
|
||||
/// it (`system/ui/scratch/lua-state-lifecycle.md`; 1291). `0x490c97` is the frame-script owner's
|
||||
/// scalar-deleting destructor — the widget tree and the native virtual-font registry — and the Lua
|
||||
/// state outlives it. The state is closed and re-opened at `0x703b80`, which reaches `InitLua` by a
|
||||
/// **tail-`jmp`** (`0x703b8e`) rather than a call, which is exactly why a call-census missed it.
|
||||
/// Its three callers are `UI_Init 0x48fbf0` (at its *head*), `ShutdownGame 0x491180`, and the glue
|
||||
/// builder `0x46a7b0` — so a logout/login cycle runs through four distinct states, and the glue
|
||||
/// screen gets its own.
|
||||
///
|
||||
/// **`PLAYER_LOGOUT` fires before any write, and that is the point**: it is an addon's last chance
|
||||
/// to mutate a saved global, so a handler that stores "where I left off" runs while the write is
|
||||
@@ -548,11 +588,54 @@ pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String
|
||||
}
|
||||
|
||||
/// `OnExit(InWorld)`: a `/logout` back to the glue, or a disconnect — two of the reference's five
|
||||
/// roots.
|
||||
fn shutdown_on_session_end(script: Option<NonSendMut<UiScript>>, id: Res<AddOnIdentity>) {
|
||||
if let Some(mut script) = script {
|
||||
shutdown_ui_state(&mut script, id.0.as_ref());
|
||||
/// roots — and, with the writes done, **the end of this session's Lua state**.
|
||||
///
|
||||
/// The reference's own shutdown ends by destroying the state (`0x490bd0`'s tail, after
|
||||
/// `AddOns.txt`); ours ends by replacing it with a fresh boot VM ([`install_boot_vm`]), which is the
|
||||
/// same guarantee expressed the way our two-phase load wants it — the character screen still needs
|
||||
/// a font-object registry for the shared glyph atlas, and the next login needs somewhere to load
|
||||
/// onto. What matters is that **no frame, no global and no addon upvalue crosses this edge**: that
|
||||
/// is what makes the next login a real login (decision 1290) instead of a re-entry into the
|
||||
/// previous character's UI.
|
||||
///
|
||||
/// Exclusive rather than a `NonSendMut` system because it both drops and installs a `NonSend`, and
|
||||
/// because the shutdown writes must be ordered against each other — see [`shutdown_ui_state`].
|
||||
pub(crate) fn end_ui_session(world: &mut World) {
|
||||
let identity = world
|
||||
.get_resource::<AddOnIdentity>()
|
||||
.and_then(|id| id.0.clone());
|
||||
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
|
||||
shutdown_ui_state(&mut script, identity.as_ref());
|
||||
}
|
||||
world.insert_resource(AddOnIdentity(None));
|
||||
|
||||
// **Everything the host is holding that came OUT of the dying VM goes with it.** A change memo
|
||||
// handles itself — it is keyed on [`benilla_ui::script::UiScript::session`] ([`VmMemo`]) — but
|
||||
// these are plain values other systems read through `Res<…>`, with no VM in hand to key
|
||||
// against, so the edge clears them. Each is a fact about a frame tree that is about to stop
|
||||
// existing: a hovered frame id, the minimap's extracted hole, a payload the cursor is carrying,
|
||||
// and the VM-relative clock the cooldown conversions run through.
|
||||
//
|
||||
// The two input latches were cleared by `char_select`'s logout and disconnect handlers, one
|
||||
// copy each. They belong here: the reason they need clearing is that `feed_ui_input` stops
|
||||
// running outside `InWorld`, which is this edge and nothing to do with *why* we left it.
|
||||
if let Some(mut hover) = world.get_resource_mut::<PlayerUiHover>() {
|
||||
hover.0 = None;
|
||||
}
|
||||
if let Some(mut keys) = world.get_resource_mut::<UiKeyboardCapture>() {
|
||||
keys.0 = false;
|
||||
}
|
||||
if let Some(mut held) = world.get_resource_mut::<CursorPayloadHeld>() {
|
||||
*held = CursorPayloadHeld::default();
|
||||
}
|
||||
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
|
||||
minimap.0 = None;
|
||||
}
|
||||
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
|
||||
*clock = UiClock::default();
|
||||
}
|
||||
|
||||
install_boot_vm(world);
|
||||
}
|
||||
|
||||
/// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather
|
||||
@@ -590,10 +673,11 @@ fn shutdown_on_exit(
|
||||
///
|
||||
/// **`PLAYER_LOGIN` is the conditional one; `PLAYER_ENTERING_WORLD` is not.** The cascade fires
|
||||
/// the former only when `[0xb4e260]` is set, and only the FrameXML-loader path sets it, clearing
|
||||
/// it immediately after — so it means "the UI came up", once, which is exactly the
|
||||
/// [`IngameUiLoaded`] latch this sits behind. `PLAYER_ENTERING_WORLD` keeps its own per-entry
|
||||
/// latch in [`crate::ui_unit`] and still lands after this, since it waits on the self descriptor
|
||||
/// arriving over the wire.
|
||||
/// it immediately after — so it means "the UI came up". That is once per **UI build**, which since
|
||||
/// 1290 is once per world entry here too: this runs from
|
||||
/// [`load_ingame_ui_on_world_entry`], on the same edge that built the tree.
|
||||
/// `PLAYER_ENTERING_WORLD` keeps its own per-entry latch in [`crate::ui_unit`] and still lands
|
||||
/// after this, since it waits on the self descriptor arriving over the wire.
|
||||
pub(crate) fn finish_ui_load(script: &mut UiScript) {
|
||||
crate::ui_saved::load_saved_variables(script);
|
||||
script.fire_event("PLAYER_LOGIN", vec![]);
|
||||
@@ -707,7 +791,7 @@ pub(crate) fn is_emote_token_line(line: &str) -> bool {
|
||||
/// no avatar in a server-less capture) and fire the initial events once — proving the full chain
|
||||
/// end-to-end on a screenshot: snapshot → `Unit*` bindings → Lua `OnEvent` → bars + text, for both
|
||||
/// unit frames (the target one included, so captures regression-test its show-on-target path).
|
||||
fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<bool>) {
|
||||
fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<bool>>) {
|
||||
/// The synthetic target's guid — a creature-family high part, so nothing mistakes it for a
|
||||
/// player. Only its *distinctness* matters.
|
||||
const DEMO_TARGET_GUID: u64 = 0xF130_0000_0000_0001;
|
||||
@@ -715,6 +799,9 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<bool>)
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// Session-keyed like every other seed (1290): the one-shot below is `PLAYER_ENTERING_WORLD`
|
||||
// and the bar seeds, which a fresh VM needs again.
|
||||
let fired = fired.get(&script);
|
||||
// TEMP DEBUG (WOW_CAPTURE_REHOVER=1): re-fire the world hover EVERY frame — the live
|
||||
// flapping-raycast simulation for the under-sized-plate investigation.
|
||||
if std::env::var("WOW_CAPTURE_REHOVER").as_deref() == Ok("1") {
|
||||
@@ -1106,6 +1193,11 @@ mod shipped_xml_tests;
|
||||
#[cfg(test)]
|
||||
mod bagnon_render_tests;
|
||||
|
||||
/// The UI's per-world-entry lifecycle: teardown at the character screen, a genuine second load at
|
||||
/// the next login (decision 1290).
|
||||
#[cfg(test)]
|
||||
mod world_entry_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod seam_scale_tests {
|
||||
use super::seam_scale;
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! **What the host remembers about the VM, and how it forgets.**
|
||||
//!
|
||||
//! The UI VM does not live for the process — it is built at world entry and destroyed at the
|
||||
//! character screen, once per login (decision 1290, the reference's `0x48fbf0` ↔ `0x490bd0`). That
|
||||
//! makes every host-side "I already told the VM about this" a claim with an expiry date, and there
|
||||
//! are two kinds of them:
|
||||
//!
|
||||
//! - **seeds** — a registry, a catalog, a keybinding set: pushed once, because pushing it is
|
||||
//! expensive and its content does not change. A `bool` latch, or a `PostStartup` system, which is
|
||||
//! the same latch written in the scheduler.
|
||||
//! - **change memos** — "these are the action slots I last pushed", so an unchanged frame costs
|
||||
//! nothing. A `Local<FeedMemory>`, near-universally.
|
||||
//!
|
||||
//! Both are *correct* against one VM and *silently wrong* against the next, and the failure has no
|
||||
//! error path: the push simply does not happen, and the window is empty. Session 2's action bar,
|
||||
//! bags, spellbook, keybinds and macros all failed exactly this way, and one of them — the CVar
|
||||
//! table — failed worse than empty: `save_config` composes `config.toml` from the VM's snapshot, so
|
||||
//! an unseeded VM would have written the player's settings file back out **stripped**.
|
||||
//!
|
||||
//! [`VmMemo`] is the one mechanism for both. It keys the memory on
|
||||
//! [`UiScript::session`] — a VM-side identity, not a host-side counter someone must remember to
|
||||
//! bump — so a memory written against a dead VM cannot be read at all. The failure mode inverts:
|
||||
//! forgetting to use it is visible (the memo is a plain `Local` again), while using it and being
|
||||
//! wrong costs one redundant push per login.
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
/// A host-side memory about the UI VM — **valid only for the VM it was written against**.
|
||||
///
|
||||
/// Wrap the memo type and read it through [`VmMemo::get`]; against a VM other than the one that
|
||||
/// last wrote it, the memo resets to `T::default()` first. The wrapped type is unchanged, so the
|
||||
/// call sites keep their own shape:
|
||||
///
|
||||
/// ```ignore
|
||||
/// mut memory: Local<VmMemo<FeedMemory>>,
|
||||
/// // …
|
||||
/// let memory = memory.get(&script);
|
||||
/// if memory.pushed.get(&id) != Some(&slot) { /* push */ }
|
||||
/// ```
|
||||
pub(crate) struct VmMemo<T> {
|
||||
/// The VM this memory is about. `0` is "no VM" — [`UiScript::session`] hands out from 1, so a
|
||||
/// freshly defaulted memo matches nothing, and the first read of every session is a miss.
|
||||
session: u64,
|
||||
inner: T,
|
||||
}
|
||||
|
||||
impl<T: Default> Default for VmMemo<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session: 0,
|
||||
inner: T::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Default> VmMemo<T> {
|
||||
/// The memory — **cleared first if this is a different VM** than the one that wrote it.
|
||||
pub(crate) fn get(&mut self, script: &UiScript) -> &mut T {
|
||||
self.get_for(Some(script))
|
||||
}
|
||||
|
||||
/// [`VmMemo::get`] for a system that also runs with **no VM** — the character screen, where the
|
||||
/// session's Lua state does not exist (1290).
|
||||
///
|
||||
/// "No VM" is a session in its own right, and it is session `0`: the memory resets once on the
|
||||
/// way into it and once on the way out, and holds in between. Reaching for a scratch default
|
||||
/// each frame instead would quietly turn the memo off for the whole glue phase.
|
||||
pub(crate) fn get_for(&mut self, script: Option<&UiScript>) -> &mut T {
|
||||
let now = script.map_or(0, UiScript::session);
|
||||
if self.session != now {
|
||||
self.session = now;
|
||||
self.inner = T::default();
|
||||
}
|
||||
&mut self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl VmMemo<bool> {
|
||||
/// **True exactly once per VM** — the seed shape: `if seeded.claim(&script) { …push the
|
||||
/// registry… }`.
|
||||
pub(crate) fn claim(&mut self, script: &UiScript) -> bool {
|
||||
let done = self.get(script);
|
||||
if *done {
|
||||
false
|
||||
} else {
|
||||
*done = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The property the whole mechanism rests on: a memo written against one VM is not readable
|
||||
/// from the next, and a seed claimed in one session is claimable again in the one after.
|
||||
#[test]
|
||||
fn a_memo_does_not_survive_the_vm_it_was_written_against() {
|
||||
let first = UiScript::new().expect("VM");
|
||||
let second = UiScript::new().expect("VM");
|
||||
assert_ne!(
|
||||
first.session(),
|
||||
second.session(),
|
||||
"every VM gets its own identity"
|
||||
);
|
||||
|
||||
let mut memo: VmMemo<Vec<u32>> = VmMemo::default();
|
||||
memo.get(&first).push(7);
|
||||
assert_eq!(memo.get(&first), &vec![7], "…and keeps it within one VM");
|
||||
assert!(
|
||||
memo.get(&second).is_empty(),
|
||||
"a memo about the previous VM reads as empty against the next one"
|
||||
);
|
||||
assert!(
|
||||
memo.get(&first).is_empty(),
|
||||
"and moving back does not resurrect it — the memory is gone, not shelved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_is_claimed_once_per_vm() {
|
||||
let first = UiScript::new().expect("VM");
|
||||
let second = UiScript::new().expect("VM");
|
||||
let mut seeded = VmMemo::<bool>::default();
|
||||
|
||||
assert!(seeded.claim(&first), "the first VM needs seeding");
|
||||
assert!(!seeded.claim(&first), "…and only once");
|
||||
assert!(seeded.claim(&second), "the next VM needs it again");
|
||||
assert!(!seeded.claim(&second));
|
||||
}
|
||||
|
||||
/// **The rule, enforced instead of remembered.**
|
||||
///
|
||||
/// A feed that memoizes what it pushed into the VM and does *not* key that memo on the session
|
||||
/// fails in the one way nothing catches: it pushes nothing into the new VM, the window is
|
||||
/// empty, and no error is raised anywhere. There is no runtime signal to test for — which is
|
||||
/// exactly why the check has to be structural.
|
||||
///
|
||||
/// So: a `Local<…>` in the parameter list of a system that also takes the VM must be a
|
||||
/// [`VmMemo`], or be named in [`EXEMPT`] with the reason it is not host-memory-about-the-VM.
|
||||
/// Adding a feed the ordinary way passes; adding one with a bare `Local` fails here, at the
|
||||
/// line, before it can reach a login.
|
||||
#[test]
|
||||
fn a_local_in_a_system_that_holds_the_vm_is_keyed_on_the_session() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = Vec::new();
|
||||
for file in rust_files(&src) {
|
||||
let text = std::fs::read_to_string(&file).expect("readable source");
|
||||
if !text.contains("UiScript") {
|
||||
continue; // no VM in this file, so no memory about one
|
||||
}
|
||||
let rel = file
|
||||
.strip_prefix(&src)
|
||||
.unwrap_or(&file)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
for params in fn_parameter_lists(&text) {
|
||||
if !params.contains("UiScript") {
|
||||
continue;
|
||||
}
|
||||
for (name, ty) in locals_in(params) {
|
||||
if ty.contains("VmMemo") || EXEMPT.contains(&(rel.as_str(), name.as_str())) {
|
||||
continue;
|
||||
}
|
||||
offenders.push(format!("{rel}: `{name}: Local<{ty}>`"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"these memos outlive the VM they are about — wrap them in \
|
||||
`crate::ui_script::VmMemo<…>` and read them through `.get(&script)`, or add them to \
|
||||
`EXEMPT` with the reason they are not memory about the VM (decision 1290):\n {}",
|
||||
offenders.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
/// `Local`s that live in a system holding the VM but are **not memory about the VM**, each with
|
||||
/// the reason. Keyed `(path under src/, parameter name)`.
|
||||
const EXEMPT: &[(&str, &str)] = &[
|
||||
// A raster fact (the screen seam), not a VM push. The measurer beside it deliberately
|
||||
// re-seats off `!script.has_text_measurer()` — it interrogates the VM instead of a memo,
|
||||
// which is the same guarantee arrived at the other way.
|
||||
("ui_script/extract/mod.rs", "last_seam"),
|
||||
// Pushed unconditionally every frame; there is nothing remembered to go stale.
|
||||
("ui_script/mod.rs", "smoothed"),
|
||||
// The cursor systems, both platform arms: these track the OS cursor and an `NSCursor` raw
|
||||
// pointer. The OS keeps that state across the VM's death and rebirth, so re-seating them at
|
||||
// a login would re-assert a cursor nothing changed. (`last_set` is the key we last handed
|
||||
// the window — the same fact under the same name on both arms.)
|
||||
("cursor.rs", "was_looking"),
|
||||
("cursor.rs", "rects_disabled"),
|
||||
("cursor.rs", "decode_failed"),
|
||||
("cursor.rs", "last_set"),
|
||||
("cursor.rs", "last_ptr"),
|
||||
];
|
||||
|
||||
/// Every `.rs` file under `root`, recursively.
|
||||
fn rust_files(root: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The parenthesised parameter list of every `fn` in `text`, by paren matching — so the scan
|
||||
/// sees a signature rather than a whole body, and a `Local` in some unrelated expression
|
||||
/// cannot be mistaken for a system parameter.
|
||||
fn fn_parameter_lists(text: &str) -> Vec<&str> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
for (i, _) in text.match_indices("fn ") {
|
||||
// `fn` must start a word: `…_fn (` and `Fn(` are not declarations.
|
||||
if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
|
||||
continue;
|
||||
}
|
||||
let Some(open) = text[i..].find('(').map(|o| i + o) else {
|
||||
continue;
|
||||
};
|
||||
// A generic parameter list can carry parens, but never before the argument list's `(`
|
||||
// in the shapes this codebase writes; a `<` with an unbalanced `(` would just scan on.
|
||||
let mut depth = 0usize;
|
||||
let mut close = None;
|
||||
for (j, c) in text[open..].char_indices() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
close = Some(open + j);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(close) = close {
|
||||
out.push(&text[open + 1..close]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every `name: Local<Ty>` in a parameter list, as `(name, Ty)` — angle-bracket matched, so a
|
||||
/// nested generic comes back whole.
|
||||
fn locals_in(params: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, _) in params.match_indices("Local<") {
|
||||
let open = i + "Local<".len();
|
||||
let mut depth = 1usize;
|
||||
let mut close = None;
|
||||
for (j, c) in params[open..].char_indices() {
|
||||
match c {
|
||||
'<' => depth += 1,
|
||||
'>' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
close = Some(open + j);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(close) = close else { continue };
|
||||
// Walk back over `: ` and `mut ` to the parameter's own name.
|
||||
let head = params[..i].trim_end().trim_end_matches(':').trim_end();
|
||||
let name = head
|
||||
.rsplit(|c: char| c == ',' || c == '(' || c.is_whitespace())
|
||||
.find(|s| !s.is_empty() && *s != "mut")
|
||||
.unwrap_or("<unnamed>");
|
||||
out.push((name.to_string(), params[open..close].trim().to_string()));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! **"The selected one is always Onewarrior no matter what char I log into."** — the director's
|
||||
//! character-switch report, reproduced at the edge that causes it.
|
||||
//!
|
||||
//! ## Why this file exists
|
||||
//!
|
||||
//! The symptom arrived as a dropdown bug: Bagnon's character menu never moved its checkmark off
|
||||
//! whoever the session started as. It is not a dropdown bug and it is not Bagnon's. Every addon on
|
||||
//! the machine reads the live character **once, at file scope** — `local currentPlayer =
|
||||
//! UnitName("player")` is the corpus idiom, not an idiosyncrasy — and until this landed the file
|
||||
//! scope ran exactly once per **process**. Logging out to the character screen and back in kept the
|
||||
//! same VM, so nothing re-read it.
|
||||
//!
|
||||
//! The tests here drive the two real edges — [`super::load_ingame_ui_on_world_entry`] and
|
||||
//! [`super::end_ui_session`] — over a planted addon that captures the name the same way, and assert
|
||||
//! the second login sees the second character. Reverting the rebuild makes
|
||||
//! [`the_second_login_runs_addon_file_scope_under_the_second_character`] report `Onehunter`, which
|
||||
//! is the director's screenshot in one string.
|
||||
//!
|
||||
//! Nothing here needs the client install or the addon corpus: the probe addon is written into a
|
||||
//! hermetic `BENILLA_HOME` by the test itself.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::Roster;
|
||||
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
|
||||
|
||||
/// **Bagnon's own idiom**, reduced to the one line that carries the bug: the live character's name,
|
||||
/// read once while the file runs, and parked where the test can see it.
|
||||
///
|
||||
/// `SwitchProbeLoads` counts file-scope runs *within one VM*, so a rebuild resets it to 1 — that
|
||||
/// number is what tells a re-entry apart from a second load stacked onto the same state.
|
||||
const PROBE_LUA: &str = "\
|
||||
local currentPlayer = UnitName(\"player\")
|
||||
SwitchProbeFileScope = currentPlayer
|
||||
SwitchProbeLoads = (SwitchProbeLoads or 0) + 1
|
||||
SwitchProbeDB = { who = currentPlayer }
|
||||
";
|
||||
|
||||
/// …and it declares that table as a per-character saved variable, so the shutdown writes a real
|
||||
/// file — which is what [`quitting_from_the_character_screen_does_not_blank_the_session_it_wrote`]
|
||||
/// watches.
|
||||
const PROBE_TOC: &str = "\
|
||||
## Interface: 11200
|
||||
## SavedVariablesPerCharacter: SwitchProbeDB
|
||||
SwitchProbe.lua
|
||||
";
|
||||
|
||||
/// A roster with a pick in flight, named — the state a world entry actually runs in
|
||||
/// ([`super::seat_from_roster`] reads exactly this).
|
||||
fn roster_named(name: &str, guid: u64) -> Roster {
|
||||
let row = benilla_protocol::Character {
|
||||
guid,
|
||||
name: name.into(),
|
||||
race: 1, // Human → Alliance
|
||||
class: 1, // Warrior
|
||||
gender: 0,
|
||||
level: 60,
|
||||
skin: 0,
|
||||
face: 0,
|
||||
hair_style: 0,
|
||||
hair_color: 0,
|
||||
facial_hair: 0,
|
||||
zone: 0,
|
||||
map: 0,
|
||||
position: benilla_protocol::wire::Vector3d {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
},
|
||||
flags: 0,
|
||||
equipment: [benilla_protocol::CharEnumItem::default(); 19],
|
||||
};
|
||||
Roster::with_pending_pick(vec![row], guid)
|
||||
}
|
||||
|
||||
/// A hermetic state folder holding one addon — the probe — and the guards that point the whole
|
||||
/// client at it. Every guard must outlive the world.
|
||||
fn hermetic_probe(tag: &str) -> (std::path::PathBuf, EnvGuard, EnvGuard) {
|
||||
// The pid keeps two concurrent `benilla_app` test binaries out of each other's tree, the same
|
||||
// reason `addons::tests::hermetic_root` carries one.
|
||||
let tmp =
|
||||
std::env::temp_dir().join(format!("benilla-world-entry-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let home = tmp.join("benilla-config");
|
||||
let dir = home.join("AddOns").join("SwitchProbe");
|
||||
std::fs::create_dir_all(&dir).expect("probe addon dir");
|
||||
std::fs::write(dir.join("SwitchProbe.toc"), PROBE_TOC).expect("probe toc");
|
||||
std::fs::write(dir.join("SwitchProbe.lua"), PROBE_LUA).expect("probe lua");
|
||||
let capture = EnvGuard::unset("WOW_CAPTURE");
|
||||
let benilla_home = EnvGuard::set("BENILLA_HOME", home.to_str().expect("utf-8 temp path"));
|
||||
(tmp, capture, benilla_home)
|
||||
}
|
||||
|
||||
/// The world a session boots into: `Startup` has run ([`super::setup_script`]), so there is a VM
|
||||
/// carrying the font registry and nothing else.
|
||||
fn booted_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<super::AddOnIdentity>();
|
||||
world.init_resource::<crate::minimap::MinimapZoom>();
|
||||
super::setup_script(&mut world);
|
||||
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) {
|
||||
world.insert_resource(roster_named(name, guid));
|
||||
super::load_ingame_ui_on_world_entry(world);
|
||||
}
|
||||
|
||||
/// What the probe addon captured at file scope this session — `None` if it never ran.
|
||||
fn probe_saw(world: &World) -> Option<String> {
|
||||
world
|
||||
.get_non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.and_then(|s| s.eval::<Option<String>>("SwitchProbeFileScope").ok())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Is a named frame our own FrameXML creates present in the live VM?
|
||||
fn frame_exists(world: &World, name: &str) -> bool {
|
||||
world
|
||||
.get_non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.and_then(|s| s.eval::<bool>(&format!("return {name} ~= nil")).ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// How many times the probe's file scope ran **in the VM that is live now**.
|
||||
fn probe_loads(world: &World) -> u32 {
|
||||
world
|
||||
.get_non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.and_then(|s| s.eval::<Option<u32>>("SwitchProbeLoads").ok())
|
||||
.flatten()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// **The director's report.** Log in as one character, log out to the character screen, log in as
|
||||
/// another: the second character's addons must see the second character.
|
||||
///
|
||||
/// Pre-fix this asserts `Onehunter` on the second login — the whole bug, in one string.
|
||||
#[test]
|
||||
fn the_second_login_runs_addon_file_scope_under_the_second_character() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("switch");
|
||||
let mut world = booted_world();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
assert_eq!(
|
||||
probe_saw(&world).as_deref(),
|
||||
Some("Onehunter"),
|
||||
"the first login's addon file scope reads the first character"
|
||||
);
|
||||
|
||||
super::end_ui_session(&mut world);
|
||||
log_in_as(&mut world, "Onewarrior", 2);
|
||||
|
||||
assert_eq!(
|
||||
probe_saw(&world).as_deref(),
|
||||
Some("Onewarrior"),
|
||||
"the second login's addon file scope must read the SECOND character — this is the \
|
||||
director's \"always Onewarrior\" report, from the other side"
|
||||
);
|
||||
assert_eq!(
|
||||
probe_loads(&world),
|
||||
1,
|
||||
"the second session is a FRESH VM, not the first one loaded twice — a second load stacked \
|
||||
onto the live state would count 2 and would have two of every frame"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// The identity the shutdown writes under follows the character, so a logout does not file the
|
||||
/// second character's saved variables under the first one's name.
|
||||
///
|
||||
/// This is the data-corruption half of the same bug: with the load latched, `AddOnIdentity` was
|
||||
/// only ever written on the first entry, so every later session's `SavedVariables` went into the
|
||||
/// first character's folder.
|
||||
#[test]
|
||||
fn the_addon_identity_follows_the_character() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("identity");
|
||||
let mut world = booted_world();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
let first = world.resource::<super::AddOnIdentity>().0.clone();
|
||||
super::end_ui_session(&mut world);
|
||||
log_in_as(&mut world, "Onewarrior", 2);
|
||||
let second = world.resource::<super::AddOnIdentity>().0.clone();
|
||||
|
||||
assert_ne!(
|
||||
first, second,
|
||||
"the enable-state / saved-variables identity is re-resolved per login"
|
||||
);
|
||||
assert_eq!(
|
||||
second.as_ref().map(|(_, c)| c.as_str()),
|
||||
Some("Onewarrior"),
|
||||
"and it names the character actually logged in"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// **Quitting from the character screen must not blank what the session already wrote.**
|
||||
///
|
||||
/// The client's shutdown runs from five roots, and two of them can fire in sequence: a `/logout`
|
||||
/// ends the session, and then the player quits from the character screen — where `AppExit` runs
|
||||
/// [`super::shutdown_ui_state`] again, now against a boot VM with no addon in it. Writing the saved
|
||||
/// variables *from* that VM would compose every file from nothing.
|
||||
///
|
||||
/// It does not, and this pins why: the three write paths each refuse an empty source
|
||||
/// (`ui_saved::save` on `names.is_empty()`, `save_enable_state` on `states.is_empty()` — its own
|
||||
/// comment already called an empty write a wipe — and `save_addon_variables` because a boot VM
|
||||
/// declares no variable sets to iterate). The reference reaches the same place with an explicit
|
||||
/// guard (`0x401ee0`'s `ds:0x882734` test: "logout then quit writes once, not twice"); ours falls
|
||||
/// out of the writers having nothing to say, which is only a *safe* answer for as long as those
|
||||
/// guards hold. Hence a test rather than a comment.
|
||||
#[test]
|
||||
fn quitting_from_the_character_screen_does_not_blank_the_session_it_wrote() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("quit");
|
||||
let mut world = booted_world();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
super::end_ui_session(&mut world);
|
||||
|
||||
let saved = crate::local_state::addon_saved_character_dir("Realm", "Onehunter")
|
||||
.expect("a hermetic home resolves the per-character saved dir")
|
||||
.join("SwitchProbe.lua");
|
||||
let after_logout = std::fs::read_to_string(&saved).expect("the logout wrote the addon's file");
|
||||
assert!(
|
||||
after_logout.contains("Onehunter"),
|
||||
"…and wrote the character it belonged to: {after_logout}"
|
||||
);
|
||||
|
||||
// Now quit — `shutdown_on_exit`'s body, against the boot VM the logout left behind.
|
||||
let identity = world.resource::<super::AddOnIdentity>().0.clone();
|
||||
let mut script = world
|
||||
.remove_non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.expect("a boot VM is live at the character screen");
|
||||
super::shutdown_ui_state(&mut script, identity.as_ref());
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&saved).ok().as_deref(),
|
||||
Some(after_logout.as_str()),
|
||||
"the quit pass wrote nothing — the session's file is byte-identical"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// Between a logout and the next login there is **no in-game UI at all** — the character screen is
|
||||
/// native, and the previous session's frame tree must not survive behind it.
|
||||
///
|
||||
/// 1051 measured what that costs when it does: probed under login-screen conditions the in-game
|
||||
/// tree emits 193 quads, invisible only because the glue screen's opaque node covers them.
|
||||
#[test]
|
||||
fn logging_out_leaves_no_in_game_frames_behind() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("teardown");
|
||||
let mut world = booted_world();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
assert!(
|
||||
probe_saw(&world).is_some(),
|
||||
"the session under test actually loaded"
|
||||
);
|
||||
|
||||
super::end_ui_session(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
probe_saw(&world),
|
||||
None,
|
||||
"the session's Lua state is gone at the character screen"
|
||||
);
|
||||
assert!(
|
||||
!frame_exists(&world, "PlayerFrame"),
|
||||
"and so is the in-game frame tree — 1051 measured 193 quads' worth of it surviving \
|
||||
behind the glue screen's opaque node"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get_non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.is_some(),
|
||||
"a boot VM stays: the character screen's text still bakes off the shared font registry"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
@@ -95,11 +95,12 @@ fn feed_shapeshift_bar(
|
||||
mut items: ResMut<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
clock: Res<crate::ui_script::UiClock>,
|
||||
mut memory: Local<StanceMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<StanceMemory>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let memory = memory.get(&script);
|
||||
let Some(spells) = spells else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -46,11 +46,12 @@ pub(super) fn feed_social(
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut chat_log: ResMut<ChatLog>,
|
||||
mut fed: Local<FedSocial>,
|
||||
mut fed: Local<crate::ui_script::VmMemo<FedSocial>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let fed = fed.get(&script);
|
||||
let areas = areas.as_deref().map(|a| &a.0);
|
||||
|
||||
// The owed system lines first: a line about a friend who just went offline should land before
|
||||
|
||||
@@ -131,11 +131,12 @@ fn feed_spellbook(
|
||||
commands: Res<NetCommands>,
|
||||
cooldowns: Res<crate::cooldowns::Cooldowns>,
|
||||
clock: Res<crate::ui_script::UiClock>,
|
||||
mut memory: Local<FeedMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let memory = memory.get(&script);
|
||||
// Nothing to resolve a name/icon/passive from yet — try again once Spell.dbc lands.
|
||||
let Some(spells) = spells.as_deref() else {
|
||||
return;
|
||||
|
||||
@@ -97,11 +97,12 @@ fn feed_talents(
|
||||
spells: Option<Res<Spells>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut chat: ResMut<ChatLog>,
|
||||
mut memory: Local<FeedMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let memory = memory.get(&script);
|
||||
let (Some(talents), Some(spells)) = (talents.as_deref(), spells.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -185,13 +185,16 @@ fn feed_taxi(
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut cache: ResMut<TaxiRouteCache>,
|
||||
mut last: Local<Option<TaxiUiState>>,
|
||||
mut last_name: Local<Option<String>>,
|
||||
mut last_riding: Local<Option<bool>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<TaxiUiState>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut last_riding: Local<crate::ui_script::VmMemo<Option<bool>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_name = last_name.get(&script);
|
||||
let last_riding = last_riding.get(&script);
|
||||
|
||||
// The activate verdict (SMSG_ACTIVATETAXIREPLY), staged by the net bridge: a refusal surfaces
|
||||
// on the red error line (the trainer/merchant UI_ERROR_MESSAGE pattern); OK clears the map —
|
||||
|
||||
@@ -354,11 +354,12 @@ fn feed_spell_tooltips(
|
||||
mut items: ResMut<Items>,
|
||||
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<SpellFeedMemory>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<SpellFeedMemory>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let memory = memory.get(&script);
|
||||
let Some(spells) = spells.as_deref() else {
|
||||
return;
|
||||
};
|
||||
@@ -592,12 +593,14 @@ fn drive_mouseover_tooltip(
|
||||
player_actions: Res<crate::ui_action::PlayerActions>,
|
||||
// The cursor seat crosses the VM seam (0582/0584): the anchor below is UI units, not px.
|
||||
ui_scale: Res<crate::ui_script::UiScaleCvar>,
|
||||
mut last: Local<LastHover>,
|
||||
mut last_lines: Local<Option<UnitState>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<LastHover>>,
|
||||
mut last_lines: Local<crate::ui_script::VmMemo<Option<UnitState>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_lines = last_lines.get(&script);
|
||||
let self_store = self_q.iter().next();
|
||||
|
||||
// The hovered UNIT's snapshot (a hovered non-unit resolves no store here).
|
||||
|
||||
@@ -345,14 +345,18 @@ fn feed_trade(
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut last: Local<Option<TradeState>>,
|
||||
mut last_open: Local<bool>,
|
||||
mut last_accept: Local<(bool, bool)>,
|
||||
mut last_player_gold: Local<u32>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<TradeState>>>,
|
||||
mut last_open: Local<crate::ui_script::VmMemo<bool>>,
|
||||
mut last_accept: Local<crate::ui_script::VmMemo<(bool, bool)>>,
|
||||
mut last_player_gold: Local<crate::ui_script::VmMemo<u32>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_open = last_open.get(&script);
|
||||
let last_accept = last_accept.get(&script);
|
||||
let last_player_gold = last_player_gold.get(&script);
|
||||
|
||||
let fresh = snapshot(&trade, &mut items, icons.as_deref(), &mut names, &commands);
|
||||
let opened = !*last_open && trade.is_open();
|
||||
|
||||
@@ -362,11 +362,12 @@ fn feed_trade_skill(
|
||||
mut items: ResMut<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
cooldowns: Res<crate::cooldowns::Cooldowns>,
|
||||
mut last: Local<Option<TradeSkillState>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<TradeSkillState>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let fresh = (|| -> Option<TradeSkillState> {
|
||||
let line = open.line?;
|
||||
let spells = spells.as_deref()?;
|
||||
|
||||
@@ -319,13 +319,16 @@ fn feed_trainer(
|
||||
mut errors: ResMut<TrainerErrors>,
|
||||
commands: Res<NetCommands>,
|
||||
mut names: ResMut<NameCache>,
|
||||
mut last: Local<Option<TrainerState>>,
|
||||
mut last_trainer: Local<Option<u64>>,
|
||||
mut last_name: Local<Option<String>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<TrainerState>>>,
|
||||
mut last_trainer: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_trainer = last_trainer.get(&script);
|
||||
let last_name = last_name.get(&script);
|
||||
// Refusals surface as the client's red error line (the merchant/equip/cast path's exact shape).
|
||||
for code in errors.0.drain(..) {
|
||||
script.fire_event(
|
||||
|
||||
@@ -150,7 +150,10 @@ impl Plugin for UiUnitPlugin {
|
||||
)
|
||||
.add_systems(Update, drain_pvp_toggles.after(UiInput))
|
||||
.add_systems(Update, feed_default_language.in_set(UnitFeed))
|
||||
.add_systems(PostStartup, (load_exhaustion_rows, load_default_languages));
|
||||
// `load_exhaustion_rows` pushes into the VM, so it runs per VM in `Update` (1290);
|
||||
// `load_default_languages` only builds a Bevy resource and stays a one-shot.
|
||||
.add_systems(Update, load_exhaustion_rows)
|
||||
.add_systems(PostStartup, load_default_languages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,11 +197,12 @@ fn feed_default_language(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
langs: Option<Res<DefaultLanguagesRes>>,
|
||||
mut pushed: Local<Option<Option<String>>>,
|
||||
mut pushed: Local<crate::ui_script::VmMemo<Option<Option<String>>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let pushed = pushed.get(&script);
|
||||
let name = self_q
|
||||
.iter()
|
||||
.next()
|
||||
@@ -212,17 +216,25 @@ fn feed_default_language(
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the VM's Exhaustion.dbc table once at startup — the rest bindings' data
|
||||
/// Seed the VM's Exhaustion.dbc table once per VM — the rest bindings' data
|
||||
/// ([`benilla_ui::script::UiScript::set_exhaustion_rows`]; the ui_macro icon-catalog shape).
|
||||
/// A failed load keeps the model's shipped-table fallback, so the rest surface still behaves
|
||||
/// like the shipped enUS client rather than going dark.
|
||||
///
|
||||
/// Per VM rather than per process (1290) for the reason every seed here is: a login builds a fresh
|
||||
/// VM, and this one degrades quietly — the fallback table is close enough that nobody would notice
|
||||
/// it had stopped being seeded.
|
||||
fn load_exhaustion_rows(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
assets: Option<Res<benilla_assets::WorldAssets>>,
|
||||
mut seeded: Local<crate::ui_script::VmMemo<bool>>,
|
||||
) {
|
||||
let (Some(mut script), Some(assets)) = (script, assets) else {
|
||||
return;
|
||||
};
|
||||
if !seeded.claim(&script) {
|
||||
return;
|
||||
}
|
||||
let loaded = {
|
||||
use benilla_assets::LockRecover;
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
@@ -909,9 +921,9 @@ fn feed_units(
|
||||
// is structural for every consumer.
|
||||
//
|
||||
// The absent arm is the world-EXIT edge (logout / char switch — the self entity despawns
|
||||
// with the streamed world): the real client fires this event on *every* world entry (it
|
||||
// tears the whole UI down between them; the once-per-process frame tree is our documented
|
||||
// interim, `IngameUiLoaded`), so re-arm the fire and forget the player-global diff
|
||||
// with the streamed world): the real client fires this event on *every* world entry, and
|
||||
// since 1290 so do we — the frame tree is torn down and rebuilt across that edge, the way
|
||||
// the reference does it. So re-arm the fire and forget the player-global diff
|
||||
// memories. Forgetting them makes every next-login first sighting re-seed SILENTLY — the
|
||||
// byte-verified fresh-CREATE notify silence (1098 §4), now holding per entry: without it a
|
||||
// 60→1 char switch latched the max-level rail shown over a level-1 body (1106's live
|
||||
|
||||
@@ -71,23 +71,24 @@ fn zone_rect(a: &WorldMapArea) -> ZoneRect {
|
||||
/// Build + push the static catalog once the patch chain, the VM, and the Map.dbc catalog all
|
||||
/// exist (Update-gated rather than Startup-ordered, like every feed that needs the script).
|
||||
fn load_world_map_ui(
|
||||
mut done: Local<bool>,
|
||||
mut done: Local<crate::ui_script::VmMemo<bool>>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
world_assets: Option<ResMut<WorldAssets>>,
|
||||
maps: Option<Res<MapCatalogRes>>,
|
||||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if *done {
|
||||
return;
|
||||
}
|
||||
let (Some(mut script), Some(assets), Some(maps), Some(areas)) =
|
||||
(script, world_assets, maps, areas)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Once per **VM** (1290), not once per process: the catalog is static, the VM it is pushed
|
||||
// into is not — a login builds a fresh one, and without this the map window has no continents.
|
||||
if !done.claim(&script) {
|
||||
return;
|
||||
}
|
||||
let areas = &areas.0;
|
||||
*done = true;
|
||||
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
let loaded = load_world_map_area_catalog(&mut chain).and_then(|wma| {
|
||||
@@ -253,11 +254,12 @@ fn feed_world_map(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||||
death_net: Res<crate::death::DeathNet>,
|
||||
mut last_explored: Local<Option<Vec<u32>>>,
|
||||
mut last_explored: Local<crate::ui_script::VmMemo<Option<Vec<u32>>>>,
|
||||
) {
|
||||
let (Some(mut script), Some(data), Some(map), Some(areas)) = (script, data, map, areas) else {
|
||||
return;
|
||||
};
|
||||
let last_explored = last_explored.get(&script);
|
||||
|
||||
// The discovery bitset (PLAYER_EXPLORED_ZONES, PRIVATE — only our own avatar carries it):
|
||||
// push on change (including the first stream-in); the engine setter queues the
|
||||
|
||||
@@ -421,8 +421,14 @@ pub struct UiScript {
|
||||
/// ([`UiScript::set_instruction_budget`]). `Arc` because the hook callback outlives this
|
||||
/// borrow; `Relaxed` because nothing orders against it — it is a bound, not a clock.
|
||||
instructions: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
/// **This VM's identity** — see [`UiScript::session`].
|
||||
session: u64,
|
||||
}
|
||||
|
||||
/// Hands out [`UiScript::session`] ids. Process-global and monotone, so an id is never reused and
|
||||
/// two of them can be compared without holding either VM.
|
||||
static NEXT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
/// How often the instruction hook fires — the resolution of an instruction budget.
|
||||
///
|
||||
/// mlua's own documentation warns that a low value "can incur a very high overhead", and nothing
|
||||
@@ -524,6 +530,7 @@ impl UiScript {
|
||||
let s = UiScript {
|
||||
lua,
|
||||
instructions: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
session: NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
};
|
||||
Ok(s)
|
||||
}
|
||||
@@ -535,6 +542,21 @@ impl UiScript {
|
||||
&self.lua
|
||||
}
|
||||
|
||||
/// **Which VM this is** — a fresh number for every [`UiScript::new`], never reused.
|
||||
///
|
||||
/// The client destroys its Lua state at logout and builds another at the next world entry
|
||||
/// (the reference's own `0x490bd0` ↔ `0x48fbf0` pair), so a host that remembers *what it last
|
||||
/// pushed into the VM* is remembering something that may no longer exist. Anything the host
|
||||
/// seeded — a registry, a catalog, a change-detection memo — is only valid for the session it
|
||||
/// was seeded into, and this number is what says so.
|
||||
///
|
||||
/// A host keying its memory on this cannot go stale by omission: a new VM simply does not
|
||||
/// match, so the seed happens again. That is the property, and it is why this is a VM-side fact
|
||||
/// rather than a host-side counter the host must remember to bump.
|
||||
pub fn session(&self) -> u64 {
|
||||
self.session
|
||||
}
|
||||
|
||||
/// **Bound how long a chunk may run, so an infinite loop reports instead of hanging.**
|
||||
///
|
||||
/// A missing capability in this engine has always been a silently WRONG ANSWER — a setter that
|
||||
|
||||
@@ -33,6 +33,44 @@ use crate::view::WorldCamera;
|
||||
/// ring capacity is `ceil(rate·lifetime)+2`; shipped trails sit far below this).
|
||||
const MAX_EDGES: usize = 512;
|
||||
|
||||
/// **What the ribbon lane did this frame** — live trails, and how many of them wrote a strip.
|
||||
///
|
||||
/// The parallel of [`crate::exterior_cull::ExteriorCullVerdict`], and here for the same reason: a
|
||||
/// trail carries no [`crate::model_render::ModelPart`] and no
|
||||
/// [`crate::particles::ParticleEmitter`], so it is invisible to the visibility census AND to the
|
||||
/// particle census. Ninety Caverns-of-Time energy trails burning across a Tanaris hillside read,
|
||||
/// on every instrument we had, as a scene with nothing in it.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct RibbonVerdict {
|
||||
/// Trails alive this frame (streaming or draining).
|
||||
pub trails: usize,
|
||||
/// …and how many committed a strip into the shared effect stream.
|
||||
pub drawn: usize,
|
||||
}
|
||||
|
||||
/// **May a trail draw, given the rooms it belongs to?** — the room half of the draw gate, pure and
|
||||
/// total so the rule is pinned by tests without an ECS (the [`crate::model_fade`] pattern that
|
||||
/// [`crate::particles::EmitterFade::in_draw_set`] already follows).
|
||||
///
|
||||
/// - No room ([`RibbonTrail::room`] `None`) — not a building's prop. Admitted; nothing claims it.
|
||||
/// - A room whose placement is resident — [`WmoGroupVis::drawn_by`], the same predicate the prop's
|
||||
/// own submeshes are culled by, so the trail and its mesh can never disagree.
|
||||
/// - A room whose placement is **gone** — refused. This is the one place the ribbon lane must NOT
|
||||
/// take the cull's usual fail-open arm: a trail outlives its building by the frame between the
|
||||
/// placement despawning and the owner cascade reaching the trail, and one frame of an additive
|
||||
/// strip hanging where a building used to be is a bright artefact, where one frame of a missing
|
||||
/// trail is nothing at all. `crate::wmo_sky`'s skybox resolve refuses on the same asymmetry.
|
||||
fn room_admits(
|
||||
room: Option<&crate::wmo_portal::WmoGroupVis>,
|
||||
instance: Option<&crate::wmo_portal::WmoPortalInstance>,
|
||||
) -> bool {
|
||||
match (room, instance) {
|
||||
(None, _) => true,
|
||||
(Some(r), Some(inst)) => r.drawn_by(inst),
|
||||
(Some(_), None) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One committed trail edge: the vertex pair across the node, world (Bevy) space, and its birth
|
||||
/// time on the shared clock.
|
||||
struct Edge {
|
||||
@@ -89,6 +127,23 @@ pub struct RibbonTrail {
|
||||
/// particle's, and inventing a ramp on top of a gate would be building past the evidence.
|
||||
/// `None` ⇒ always drawn (a placed prop, an effect instance).
|
||||
alpha_src: Option<Entity>,
|
||||
/// The **rooms whose visibility this trail rides** — a WMO prop's own
|
||||
/// [`crate::wmo_portal::WmoGroupVis`], carried by value because a trail entity must not hold
|
||||
/// that component (it would enlist the trail in `apply_model_visibility`'s group query, a
|
||||
/// second `Visibility` writer on an entity whose `Visibility` nobody reads — decision 0025).
|
||||
///
|
||||
/// It exists because **a trail is one of its model's emitters, and the reference draws a
|
||||
/// WMO's props out of each VISIBLE group's own MODR list** (`0x695aa0` from the visible-group
|
||||
/// walk `0x698720`, decision 0689) — a prop in a culled room is never instantiated, so it
|
||||
/// trails nothing. We culled the prop's *mesh* and left its ribbons streaming, which is the
|
||||
/// same omission decision 0786 fixed one lane over for particle emitters, and the same shape
|
||||
/// as 1283's shadow/nameplate/picker: a rider that never asked whether its owner was in the
|
||||
/// scene. Standing in Tanaris, Caverns of Time's twelve energy-trail props kept ninety
|
||||
/// additive trails burning up through 200 yd of rock (decision 1289).
|
||||
///
|
||||
/// `None` = not a building's prop (an ADT map doodad, a creature, a missile, a held item):
|
||||
/// nothing claims it, so nothing gates it here.
|
||||
room: Option<crate::wmo_portal::WmoGroupVis>,
|
||||
/// Committed edges, newest at the back. The live head (the current node) is appended at
|
||||
/// render time only, so the trail always connects to the emitter between commits.
|
||||
edges: VecDeque<Edge>,
|
||||
@@ -156,6 +211,7 @@ pub fn spawn_ribbon(
|
||||
owner_scale: f32,
|
||||
seq: RibbonSeq,
|
||||
alpha_src: Option<Entity>,
|
||||
room: Option<crate::wmo_portal::WmoGroupVis>,
|
||||
) -> Option<Entity> {
|
||||
// Perf-bisect kill-switch: $WOW_NO_PARTICLES also spawns no ribbons (one switch, whole family).
|
||||
if std::env::var_os("WOW_NO_PARTICLES").is_some() {
|
||||
@@ -192,6 +248,7 @@ pub fn spawn_ribbon(
|
||||
owner: Some(owner),
|
||||
seq,
|
||||
alpha_src,
|
||||
room,
|
||||
edges: VecDeque::new(),
|
||||
accumulator: 0.0,
|
||||
age: 0.0,
|
||||
@@ -229,6 +286,9 @@ pub(crate) fn simulate_ribbons(
|
||||
// The water-plane interleave inputs — a trail is one of the model's emitters and classifies
|
||||
// above/below water like the quad clouds ([`crate::particles::far_side_of_water`]).
|
||||
interleave: crate::particles::WaterInterleave,
|
||||
// The per-frame portal PVS, for the room gate ([`RibbonTrail::room`]).
|
||||
portal_instances: Query<&crate::wmo_portal::WmoPortalInstance>,
|
||||
mut verdict: ResMut<RibbonVerdict>,
|
||||
mut trails: Query<(
|
||||
Entity,
|
||||
&mut RibbonTrail,
|
||||
@@ -236,6 +296,7 @@ pub(crate) fn simulate_ribbons(
|
||||
&mut GlobalTransform,
|
||||
)>,
|
||||
) {
|
||||
*verdict = RibbonVerdict::default();
|
||||
let Ok(cam) = world_cam.single() else {
|
||||
return;
|
||||
};
|
||||
@@ -248,6 +309,7 @@ pub(crate) fn simulate_ribbons(
|
||||
owner,
|
||||
seq,
|
||||
alpha_src,
|
||||
room,
|
||||
edges,
|
||||
accumulator,
|
||||
age,
|
||||
@@ -285,6 +347,7 @@ pub(crate) fn simulate_ribbons(
|
||||
commands.entity(entity).despawn();
|
||||
continue;
|
||||
}
|
||||
verdict.trails += 1;
|
||||
|
||||
// The `+0xc0` **enable** gate, sampled LIVE against the sequence the host is playing.
|
||||
// SETTLED at the bytes (wow-re `ribbon-emitter-spec.md` §7, closed — the dispatch this
|
||||
@@ -406,6 +469,16 @@ pub(crate) fn simulate_ribbons(
|
||||
if alpha_src.is_some_and(|e| model_alphas.get(e) <= 1e-3) {
|
||||
continue;
|
||||
}
|
||||
// …and the room gate, for the same reason one line up and with the same shape as `lit`:
|
||||
// the sim above still ran, only the DRAW is skipped, so walking back into the room resumes
|
||||
// mid-strip instead of from empty ([`RibbonTrail::room`]).
|
||||
if !room_admits(
|
||||
room.as_ref(),
|
||||
room.as_ref()
|
||||
.and_then(|r| portal_instances.get(r.instance).ok()),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let n = edges.len() + usize::from(head.is_some());
|
||||
if n < 2 {
|
||||
continue;
|
||||
@@ -449,6 +522,7 @@ pub(crate) fn simulate_ribbons(
|
||||
((now - e.born) / def.edge_lifetime).clamp(0.0, 1.0),
|
||||
));
|
||||
}
|
||||
verdict.drawn += 1;
|
||||
let start = quads.begin();
|
||||
for w in pairs.windows(2) {
|
||||
let ((t0, b0, a0), (t1, b1, a1)) = (w[0], w[1]);
|
||||
@@ -520,7 +594,7 @@ impl Plugin for RibbonPlugin {
|
||||
// PostUpdate, after the billboard joint palette — same law and reason as the particle
|
||||
// sim: a trail node on a billboarded/animated bone must sample the frame the palette
|
||||
// just wrote (see `billboard_joint_palette`'s consumer note).
|
||||
app.add_systems(
|
||||
app.init_resource::<RibbonVerdict>().add_systems(
|
||||
PostUpdate,
|
||||
simulate_ribbons
|
||||
.in_set(crate::billboard::BillboardPlace)
|
||||
@@ -532,7 +606,52 @@ impl Plugin for RibbonPlugin {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::gravity_step;
|
||||
use super::{gravity_step, room_admits};
|
||||
use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance};
|
||||
use bevy::math::Affine3A;
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// **A trail is one of its model's emitters, so it rides the rooms its model is drawn by.**
|
||||
///
|
||||
/// The reference instantiates a WMO's props out of each VISIBLE group's own MODR list
|
||||
/// (`0x695aa0` from the visible-group walk `0x698720`, decision 0689) — a prop in a culled
|
||||
/// room is never created, so it trails nothing. Our prop's *mesh* was culled by exactly this
|
||||
/// predicate and its ribbons were not, which is how Caverns of Time's twelve energy-trail
|
||||
/// props kept ninety additive strips burning up through 200 yd of rock into Tanaris while
|
||||
/// every one of their submeshes was correctly hidden (decision 1289).
|
||||
#[test]
|
||||
fn a_prop_in_a_culled_room_trails_nothing_and_an_orphan_trail_refuses() {
|
||||
let inst = |visible: Vec<bool>| WmoPortalInstance {
|
||||
handle: Handle::default(),
|
||||
world_from_local: Affine3A::IDENTITY,
|
||||
name_set: 0,
|
||||
liquid_visited: vec![false; visible.len()],
|
||||
flooded: vec![None; visible.len()],
|
||||
visible,
|
||||
};
|
||||
let room = |groups: &[u16]| WmoGroupVis {
|
||||
instance: Entity::PLACEHOLDER,
|
||||
groups: groups.into(),
|
||||
};
|
||||
let cot = inst(vec![true, false, false]);
|
||||
|
||||
// Not a building's prop at all — an ADT map doodad, a creature, a held item's streak.
|
||||
assert!(room_admits(None, None), "an unclaimed trail is never gated");
|
||||
// The room the flood reached draws; the ones it did not, do not.
|
||||
assert!(room_admits(Some(&room(&[0])), Some(&cot)));
|
||||
assert!(!room_admits(Some(&room(&[1, 2])), Some(&cot)));
|
||||
// A prop several rooms name draws while ANY of them is visible — the same
|
||||
// `drawn_by` law the submeshes take, which is the point of sharing the predicate.
|
||||
assert!(room_admits(Some(&room(&[1, 0])), Some(&cot)));
|
||||
|
||||
// …and the asymmetry that must NOT be the cull's usual fail-open: a trail whose
|
||||
// placement has despawned refuses, rather than drawing one last frame through the hole
|
||||
// where a building used to be.
|
||||
assert!(
|
||||
!room_admits(Some(&room(&[0])), None),
|
||||
"an orphaned trail draws nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The per-frame gravity term telescopes to the closed form the bytes imply: stepping
|
||||
/// `gravity · ((2·age) + dt) · dt` and advancing `age` by `dt` leaves an edge exactly
|
||||
|
||||
@@ -193,6 +193,10 @@ pub(super) fn spawn_ribbons_for(
|
||||
joints: Option<&[Entity]>,
|
||||
arm: Option<Entity>,
|
||||
fallback_owner: Option<Entity>,
|
||||
// The rooms this placement is drawn by, for a WMO prop — `None` for an ADT map doodad. The
|
||||
// trail's own gate, because a trail rides a joint or a submesh and neither of those states it
|
||||
// ([`crate::ribbons::RibbonTrail::room`]).
|
||||
room: Option<crate::wmo_portal::WmoGroupVis>,
|
||||
) {
|
||||
for rb in ribbons {
|
||||
let (owner, use_pivot) = match joints.and_then(|js| js.get(rb.def.bone as usize)).copied() {
|
||||
@@ -217,6 +221,7 @@ pub(super) fn spawn_ribbons_for(
|
||||
),
|
||||
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
|
||||
None,
|
||||
room.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
host.as_ref().map(|h| h.joints.as_slice()),
|
||||
host.as_ref().and_then(|h| h.arm),
|
||||
ents.first().copied(),
|
||||
None, // an ADT map doodad belongs to no building
|
||||
);
|
||||
spawn_lights_for(&mut commands, &m.lights, p.transform, &mut ents);
|
||||
tag_world_object(
|
||||
@@ -622,6 +623,14 @@ pub(super) fn spawn_loaded_placements(
|
||||
host.as_ref().map(|h| h.joints.as_slice()),
|
||||
host.as_ref().and_then(|h| h.arm),
|
||||
ents.first().copied(),
|
||||
// The prop's own rooms — the trail is gated by them exactly as the submeshes
|
||||
// above are, because it is one of this model's emitters (decision 1289).
|
||||
portal_instance
|
||||
.filter(|_| !d.groups.is_empty())
|
||||
.map(|instance| WmoGroupVis {
|
||||
instance,
|
||||
groups: d.groups.clone(),
|
||||
}),
|
||||
);
|
||||
spawn_lights_for(&mut commands, &m.lights, d.transform, &mut ents);
|
||||
tag_world_object(
|
||||
|
||||
@@ -50,6 +50,11 @@ pub struct WorldCensus<'w, 's> {
|
||||
claim: Option<Res<'w, CameraInteriorClaim>>,
|
||||
windows: Option<Res<'w, ExteriorWindows>>,
|
||||
verdict: Option<Res<'w, ExteriorCullVerdict>>,
|
||||
/// Which backdrop is drawing — the gradient dome, or a building's own MOSB sky
|
||||
/// ([`crate::wmo_sky`]). Optional for the same reason as the portal terms above.
|
||||
skybox: Option<Res<'w, crate::wmo_sky::CameraWmoSkybox>>,
|
||||
/// The ribbon lane's own verdict — the third effect population, counted nowhere else.
|
||||
ribbons: Option<Res<'w, crate::ribbons::RibbonVerdict>>,
|
||||
mats: Res<'w, Assets<WowModelMaterial>>,
|
||||
meshes: Res<'w, Assets<Mesh>>,
|
||||
images: Res<'w, Assets<bevy::image::Image>>,
|
||||
@@ -89,11 +94,24 @@ pub struct CensusReport {
|
||||
pub emitters: usize,
|
||||
pub active_emitters: usize,
|
||||
pub particles: usize,
|
||||
/// Ribbon trails alive, and how many wrote a strip — `None` if the lane is not installed.
|
||||
/// The `emitters`/`particles` pair above covers only the QUAD half of the effect stream;
|
||||
/// trails are the other half and had no counter at all.
|
||||
pub ribbons: Option<(usize, usize)>,
|
||||
/// The WMO group the camera claims — `"g07"`, or `"none"` over open world. `None` when the
|
||||
/// portal system is not installed at all, which is a different statement from "not indoors".
|
||||
pub room: Option<String>,
|
||||
/// `"unrestricted"`, or the number of window sub-frusta. `None` with [`CensusReport::room`].
|
||||
pub windows: Option<String>,
|
||||
/// The backdrop actually drawing: `"dome"` for the `Light.dbc` gradient, else the WMO skybox
|
||||
/// model a building's PVS asked for. `None` when the WMO-sky lane is not installed.
|
||||
///
|
||||
/// Here because a skybox is the one draw that can repaint the whole frame while being
|
||||
/// invisible to every other line in this report: its batches carry no [`ModelPart`], so they
|
||||
/// are not in `submeshes`, not in `drawn`, not in `kinds`, and not in `labels`. Standing
|
||||
/// outdoors in Tanaris with a purple sky and no purple *object* on any list is exactly the
|
||||
/// state that has to be readable, and before this it was not.
|
||||
pub sky: Option<String>,
|
||||
/// What the exterior cull did this frame, `None` if it did not run.
|
||||
pub cull: Option<CullTerms>,
|
||||
/// Resident asset counts — the leak meter. A tour probe reading the same counts as a fresh
|
||||
@@ -225,6 +243,11 @@ impl WorldCensus<'_, '_> {
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
let sky = self.skybox.as_ref().map(|s| {
|
||||
s.0.as_deref()
|
||||
.map_or_else(|| "dome".to_string(), str::to_ascii_lowercase)
|
||||
});
|
||||
|
||||
CensusReport {
|
||||
submeshes,
|
||||
drawn,
|
||||
@@ -240,6 +263,8 @@ impl WorldCensus<'_, '_> {
|
||||
particles,
|
||||
room,
|
||||
windows,
|
||||
sky,
|
||||
ribbons: self.ribbons.as_deref().map(|r| (r.trails, r.drawn)),
|
||||
cull: self.verdict.as_deref().map(|v| CullTerms {
|
||||
windows: v
|
||||
.windows
|
||||
|
||||
Reference in New Issue
Block a user