ReloadUI works, and a building's lights obey their rooms

- ReloadUI() tears the UI down and rebuilds it in place without leaving the world — the reference's own pair, one frame apart
- a model's particle clouds and ribbon trails are admitted by one rule — distance, far clip, frustum, window, room — and a building's light sources ride their rooms too, so torches stop lighting receivers through walls
- a mouse-wheel notch is a press and its release, dispatched in the same frame as the reference does
- the binding registry is checked against the install's own Bindings.xml and DefaultBindings.wtf, so its transcribed defaults cannot drift silently

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:28:13 +07:00
co-authored by Claude Fable 5
parent 7d826e673a
commit 6ec7cc915b
36 changed files with 1582 additions and 362 deletions
+191 -21
View File
@@ -383,11 +383,25 @@ fn latch_and_dispatch(
over_ui: Res<PointerOverUi>,
dispatch: Res<BindingDispatch>,
mut state: ResMut<BindingsState>,
mut same_vm: Local<crate::ui_script::VmMemo<bool>>,
) {
state.just.clear();
state.fired.clear();
state.amounts.clear();
// A latch indexes the dispatch table snapshotted from the VM that latched it. When the VM is
// replaced mid-hold (a `/reload` with a key down), releasing against the NEW table would run
// the wrong addon's `keystate="up"` body — or swallow the release and leave a Held latched
// with no Stop. So latches die with the VM they were made against (decision 1291); a key
// still physically down re-latches on its next press edge. The reference keeps a held key
// running through a `ReloadUI` (its dispatch is engine-side) — dropping is the safe
// divergence, over the moment the key is pressed again.
if let Some(script) = script.as_ref() {
if same_vm.claim(script) {
state.latched.clear();
}
}
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight);
@@ -552,8 +566,13 @@ fn latch_and_dispatch(
}
}
// ── Wheel ── a notch is a press with no release (which is exactly why the table refuses
// wheel chords on runOnUp commands); over UI the wheel belongs to the hovered frame.
// ── Wheel ── **a notch is a press AND its release, back to back.** The reference builds one
// chord and hands it to `CBindings::ExecuteBinding` twice — `isDown=1` at `0x483d6f`, then
// `isDown=0` at `0x483d82` (wow-re `system/ui/ui.md` §3, VERIFIED) — so a `runOnUp` command
// runs both halves in the same frame and a plain one runs its single half (the up leg is the
// `RunCommand 0x4b7b50` no-op: `UP + !runOnUp` returns without running anything). Before this
// the notch was a press with no release, which quietly made every press+release command a
// dead wheel binding. Over UI the wheel belongs to the hovered frame.
// Trackpads report pixel deltas — normalized to line-equivalents so the zoom consumer's
// feel is unchanged from when it read the scroll itself.
let wheel = match scroll.unit {
@@ -575,25 +594,22 @@ fn latch_and_dispatch(
key,
};
match dispatch.resolve(chord, false) {
Some(Bound::Spec(cmd)) => match &SPECS[cmd.0 as usize].kind {
Kind::Edge(lua) => run_lua(&mut script, lua, SPECS[cmd.0 as usize].name),
Kind::Host => {
state.fired.push(cmd);
state.amounts.push((cmd, amount));
}
// Unreachable by construction (SetBinding refuses these); harmless if a
// hand-edited file smuggles one in.
Kind::Held | Kind::EdgeUpDown(..) => {}
},
// An addon binding on the wheel is a one-shot press, the `Kind::Edge` case. A
// `runOnUp` one cannot be here at all — its entry carries the same press+release flag
// a Held/EdgeUpDown command does, and the table's wheel refusal reads exactly that.
Some(Bound::Addon(i)) => {
if let Some(a) = dispatch.addons.get(i as usize) {
if !a.run_on_up {
run_addon(&mut script, a, "down");
}
}
// A host command is the one thing the press/release pair cannot carry: its input is
// the notch's ANALOG MAGNITUDE (the camera zoom's), where [`press`] can only spend
// the reference's 1.0 key step. It has no release half either way.
Some(Bound::Spec(cmd)) if matches!(SPECS[cmd.0 as usize].kind, Kind::Host) => {
state.fired.push(cmd);
state.amounts.push((cmd, amount));
}
// Everything else is the reference's own pair. `Kind::Edge` runs its one body and
// has nothing to release; `Kind::EdgeUpDown` runs BOTH halves now (a wheel-bound
// action button presses and releases in the notch, which is what makes it cast); an
// addon's `runOnUp` body runs twice, `keystate` "down" then "up". `Kind::Held`
// latches and unlatches before any consumer can observe it — which is the
// reference's behaviour too, its movement bit being set and cleared in the one tick.
Some(bound) => {
press(&mut state, &mut script, run_lua, &dispatch, bound, key);
release(&mut state, &mut script, run_lua, &dispatch, key);
}
None => {}
}
@@ -985,6 +1001,160 @@ mod tests {
);
}
/// **The armed capture seam, driven by real input events** (B265). The page's own tests call
/// `KeyBindings_OnHostKey` directly, so nothing asserted that a real notch/press ever
/// produces that call — and the wheel is the one input that reaches this branch through
/// neither `KeyboardInput` nor `MouseButtonInput`.
#[test]
fn an_armed_capture_takes_a_wheel_notch() {
let mut script = UiScript::new().expect("VM");
script.register_bindings(&registry_commands());
script
.run(
r#"CAPTURED = nil
function KeyBindings_OnHostKey(chord) CAPTURED = chord end
BenillaBindCapture(true)"#,
)
.expect("arm");
let mut app = vm_harness(script);
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 1.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert_eq!(
lua_str(&app, "tostring(CAPTURED)"),
"MOUSEWHEELUP",
"a wheel notch while armed is a binding key"
);
assert!(
!state(&app).fired(cmd::CAMERA_ZOOM_IN),
"the armed seam swallows the notch — it must not also zoom"
);
// Down, and a modified notch: the canonical prefix order rides the same path.
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -1.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert_eq!(lua_str(&app, "tostring(CAPTURED)"), "MOUSEWHEELDOWN");
press_key(&mut app, KeyCode::ShiftLeft);
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 1.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert_eq!(lua_str(&app, "tostring(CAPTURED)"), "SHIFT-MOUSEWHEELUP");
}
/// **The whole wheel-bind path in one harness** (B265): the real Keybindings page, a capsule
/// armed by a real click, a real notch fed the way winit feeds it, and then the bound chord
/// dispatching. The page's own tests call `KeyBindings_OnHostKey` by hand and this module's
/// tests carry no page — between them the join was never asserted.
#[test]
fn a_wheel_notch_binds_through_the_real_page_and_then_dispatches() {
let by_name =
|n: &str| Cmd(SPECS.iter().position(|s| s.name == n).expect("registered") as u16);
let mut s = crate::ui_script::keybindings_tests::harness();
crate::ui_script::keybindings_tests::on_page(&mut s);
const ROW: &str = "OptionsFrameContainerBodyKeybindingsRow";
// Expand Movement and arm JUMP's first capsule — JUMP is the classic wheel bind, and one
// of the 1.12 commands that is NOT `runOnUp`, so the reference accepts the wheel on it.
s.run(&format!("{ROW}1Header:Click()")).expect("expand");
s.run(&format!("{ROW}9Key1Button:Click()")).expect("select");
assert_eq!(
s.eval::<String>(&format!("return {ROW}9Description:GetText()"))
.unwrap(),
"JUMP"
);
assert!(s.bind_capture_armed());
let mut app = vm_harness(s);
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 1.0,
window: Entity::PLACEHOLDER,
});
app.update();
{
let s = app.world().non_send_resource::<UiScript>();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("MOUSEWHEELUP")"#)
.unwrap(),
"JUMP",
"a notch on an armed capsule is a bind"
);
assert!(!s.bind_capture_armed(), "the completed bind disarms");
}
// …and the bound chord now dispatches: the next notch jumps rather than binding.
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 1.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert!(state(&app).fired(by_name("JUMP")));
assert!(
!state(&app).fired(cmd::CAMERA_ZOOM_IN),
"JUMP stole the wheel from the camera, the 1.12 steal law"
);
}
/// **A notch is a press AND its release** — the reference dispatches the one chord twice,
/// `isDown=1` then `isDown=0` (`0x483d6f` / `0x483d82`). So a `runOnUp` binding on the wheel
/// runs both halves in the same frame; before this it ran neither, and the binding was dead.
///
/// Seeded through the stored set rather than `SetBinding`, because the table refuses to put a
/// wheel chord on a press+release command — this is the "hand-edited file smuggles one in"
/// case, which is exactly the state the old arm silently dropped on the floor.
#[test]
fn a_wheel_notch_runs_both_halves_of_a_press_and_release_binding() {
let mut script = UiScript::new().expect("VM");
script.register_bindings(&registry_commands());
script.register_addon_bindings(
"ProbeAddon",
&benilla_ui::bindings_xml::parse(PROBE_BINDINGS).expect("well-formed"),
);
script.seed_binding_set(
1,
Some(vec![(
"PROBEHOLD".to_string(),
vec!["MOUSEWHEELUP".to_string()],
)]),
);
script.load_binding_set(1);
let mut app = vm_harness(script);
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 1.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert_eq!(lua_count(&app, "PROBE_DOWN"), 1, "the notch's press half");
assert_eq!(
lua_count(&app, "PROBE_UP"),
1,
"…and its release half, in the same frame — a notch has no key left to lift"
);
assert_eq!(lua_str(&app, "PROBE_LAST"), "up");
// Nothing is left latched: a wheel latch that outlived its notch would hold the down
// state forever, with no key to press to end it.
assert!(app.world().resource::<BindingsState>().latched.is_empty());
}
#[test]
fn held_commands_latch_across_frames_and_release_per_base_key() {
let mut app = harness();
@@ -1124,4 +1124,82 @@ mod tests {
assert!(seen.insert(s.name), "duplicate command {}", s.name);
}
}
/// **The registry against the real client's own two files.** Two columns of this table are
/// pure transcription from the 1.12 install, and both are load-bearing in a way nothing else
/// here can see: `runOnUp` — the mousewheel-refusal law rides it, and B265 turned entirely on
/// whether ours matched — and every command's default chords. A hand-typed table drifts; this
/// reads the source of truth instead of trusting the typing.
///
/// `Interface\FrameXML\Bindings.xml` carries the flag, `WTF\DefaultBindings.wtf` the chords.
/// Both are install content, read at runtime, never committed; the test skips (passes)
/// without a client, the house pattern for a real-data test. Running Blizzard's own 234-row
/// file through our [`benilla_ui::bindings_xml`] is a second gate riding along — that is the
/// parser every addon's `Bindings.xml` lands in.
#[test]
fn the_registry_matches_the_installs_own_bindings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open the 1.12 patch chain");
let xml = String::from_utf8_lossy(
&chain
.read_file("Interface\\FrameXML\\Bindings.xml")
.expect("the install carries Bindings.xml"),
)
.into_owned();
let reference = benilla_ui::bindings_xml::parse(&xml).expect("Blizzard's own file parses");
let run_on_up: std::collections::HashMap<&str, bool> = reference
.iter()
.map(|b| (b.name.as_str(), b.run_on_up))
.collect();
// `bind <CHORD> <COMMAND>`, in the file's own order — which is the order the command's
// Key 1 and Key 2 slots take.
let wtf = String::from_utf8_lossy(
&chain
.read_file("WTF\\DefaultBindings.wtf")
.expect("the install carries DefaultBindings.wtf"),
)
.into_owned();
let mut defaults: std::collections::HashMap<&str, Vec<&str>> =
std::collections::HashMap::new();
for line in wtf.lines() {
let mut it = line.split_whitespace();
if let (Some("bind"), Some(chord), Some(command), None) =
(it.next(), it.next(), it.next(), it.next())
{
defaults.entry(command).or_default().push(chord);
}
}
for s in SPECS {
let Some(&reference_run_on_up) = run_on_up.get(s.name) else {
panic!(
"{} is not a 1.12 binding at all — the tree is meant to be honest",
s.name
);
};
assert_eq!(
s.run_on_up(),
reference_run_on_up,
"{}: runOnUp disagrees with the install's Bindings.xml — that flag decides \
whether the mousewheel may be bound to this command (B265)",
s.name
);
let ours: Vec<&str> = [s.d1, s.d2].into_iter().flatten().collect();
if s.name == "OPENALLBAGS" {
// The one recorded divergence (0997): 1.12 splits `B`/TOGGLEBACKPACK from
// `SHIFT-B`/OPENALLBAGS, and benilla's single bag knob is the open-all toggle
// that already lived on B — so it ships wearing both.
assert_eq!(ours, ["B", "SHIFT-B"]);
continue;
}
assert_eq!(
ours,
defaults.get(s.name).cloned().unwrap_or_default(),
"{}: default chords disagree with the install's DefaultBindings.wtf",
s.name
);
}
}
}
+112
View File
@@ -347,6 +347,18 @@ fn sync_cvars(
return;
};
if persist.registered.claim(&script) {
// The config file's values go in FIRST (decision 1291): registration — ours below, or an
// addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a
// knobless CVar (`statusBarText`) and an addon-declared one across a VM replacement; the
// knob-derived session rows below still win for every key a host knob backs, and an
// env-overridden key keeps its env value the same way (its knob carries it).
script.set_cvar_saved_base(
persist
.file
.iter()
.filter(|(k, _)| !persist.env_overridden.contains(&k.to_ascii_lowercase()))
.map(|(k, v)| (k.clone(), v.clone())),
);
script.register_cvars(REGISTERED.iter().copied());
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 23] = [
@@ -409,6 +421,106 @@ fn sync_cvars(
}
}
/// Fold the dying VM's CVar table into the persist state — the session edge's half of decision
/// 1291's bridge (the seed in [`sync_cvars`] is the other). Called from
/// [`crate::ui_script::end_ui_session`] **after** the shutdown events (an addon's
/// `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference that write lands in an
/// engine-side store that survives) and **before** the VM is replaced.
///
/// Two steps, both about the writes the per-frame sync never got to see:
/// 1. drain the dying VM's change queue into the host knobs — a `SetCVar` in the final frame
/// would otherwise be overwritten by the stale knob when the next VM's seed runs;
/// 2. fold the table into `persist.file` with the same compose the saver uses, so the next VM's
/// saved base — and the next save — both start from what the player actually set.
///
/// `dirty` is left alone: if nothing changed, the fold is an identity; if something did, the
/// change that did it already marked the config dirty.
#[allow(clippy::type_complexity)] // one Option per knob resource, the same census sync_cvars keeps
pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
// Every param is `Option` because this runs from an exclusive edge function, not the app's
// schedule: a test world or a stripped scenario may not carry the cvar plugin at all, and
// "no persist state" simply means there is no file to bridge — not a panic.
let mut state: bevy::ecs::system::SystemState<(
Option<NonSendMut<UiScript>>,
Option<ResMut<CvarPersist>>,
Option<ResMut<SoundConfig>>,
Option<ResMut<UiScaleCvar>>,
Option<ResMut<ViewDistance>>,
Option<ResMut<LookConfig>>,
Option<ResMut<ClickConfig>>,
Option<ResMut<LootConfig>>,
Option<ResMut<NameConfig>>,
Option<ResMut<ClutterConfig>>,
Option<ResMut<MinimapZoom>>,
Option<ResMut<BubbleConfig>>,
Option<ResMut<ZoomLimit>>,
)> = bevy::ecs::system::SystemState::new(world);
let (
script,
persist,
sound,
scale,
view,
look,
click,
loot,
names,
clutter,
minimap,
bubbles,
zoom,
) = state.get_mut(world);
let (Some(mut script), Some(mut persist)) = (script, persist) else {
return;
};
let changes = script.take_cvar_changes();
if !changes.is_empty() {
// The knobs exist whenever the app is real (each plugin inits its own); a world that
// carries CvarPersist but not the knobs is a partial test rig, where dropping the
// final-frame drain is the right degradation — the fold below still preserves the file.
if let (
Some(mut sound),
Some(mut scale),
Some(mut view),
Some(mut look),
Some(mut click),
Some(mut loot),
Some(mut names),
Some(mut clutter),
Some(mut minimap),
Some(mut bubbles),
Some(mut zoom),
) = (
sound, scale, view, look, click, loot, names, clutter, minimap, bubbles, zoom,
) {
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
view: &mut view,
look: &mut look,
click: &mut click,
loot: &mut loot,
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
};
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
persist.dirty = true;
persist.last_change = Some(Instant::now());
}
}
}
}
let snapshot = script.cvars_snapshot();
if snapshot.is_empty() {
return; // a VM that never registered (a capture) has nothing to say about the file
}
persist.file = compose_file(&persist.file, &persist.env_overridden, &snapshot);
}
/// Compose the file to save: the previous file as the merge base, every registered var that
/// moved off its default written, every one back at its default removed — env-overridden keys
/// untouched (the session value is the env's, not the player's).
+67 -30
View File
@@ -81,18 +81,37 @@ pub(crate) struct DeathNet {
pub(crate) water_walk: bool,
}
/// The feed's change-tracking memory (the [`crate::ui_unit::UnitFeedState`] pattern — guid-keyed,
/// first-snapshot-counts-as-edge so logging in dead brings the popup up).
/// The feed's memory, in **two scopes** (decisions 1290/1291): the body's own state machine is
/// world memory and survives a `/reload`; what this VM has been *told* dies with the VM.
///
/// The reference draws the same line by construction — its death mirror is engine-side (wow-re
/// death-ui.md §1), so a `ReloadUI` neither re-arms the release window nor loses it, while the
/// rebuilt UI repaints from the surviving state. Ours mirrors that: `mirror`/`died_at` stay put
/// across a VM replacement, and the announce latches reset with the VM so the fresh frame tree
/// hears the events the old one consumed.
#[derive(Resource, Default)]
struct DeathFeedState {
/// The last `(guid, dead, ghost)` we saw for the self player; `None` before the first
/// snapshot (and after a despawn — a worldport recreates the entity, same guid).
last: Option<(u64, bool, bool)>,
/// `Time::elapsed_secs_f64` at the death edge — the anchor of the client-side release window
/// (byte-VERIFIED, wow-re death-ui.md §1: the wire carries only the PLAYER_FIELD_BYTES timer
/// BIT; the client arms `now + 360000 ms` at its own alive→dead mirror-edge — so a
/// login-while-dead re-arms at login exactly like ours, and expiry reads 0, never negative).
/// **World-scoped.** The last `(guid, dead, ghost)` the self body showed — the edge that
/// arms and clears the release window. `None` before a world session's first snapshot.
mirror: Option<(u64, bool, bool)>,
/// **World-scoped.** `Time::elapsed_secs_f64` at the death edge — the anchor of the
/// client-side release window (byte-VERIFIED, wow-re death-ui.md §1: the wire carries only
/// the PLAYER_FIELD_BYTES timer BIT; the client arms `now + 360000 ms` at its own
/// alive→dead mirror-edge — so a login-while-dead re-arms at login exactly like ours, and
/// expiry reads 0, never negative). Deliberately NOT per-VM: a `/reload` must not restart
/// the six minutes — the reference's engine-side window keeps running through one.
died_at: Option<f64>,
/// **VM-scoped** — the announce latches, keyed on the VM they announced to.
vm: crate::ui_script::VmMemo<DeathAnnounced>,
}
/// What the live VM has been told about the death state — [`DeathFeedState::vm`]'s payload
/// (guid-keyed, first-snapshot-counts-as-edge so logging in dead brings the popup up — and so
/// does reloading dead, which is the same situation from the frame tree's point of view).
#[derive(Default)]
struct DeathAnnounced {
/// The last `(guid, dead, ghost)` THIS VM was given an event for; `None` for a fresh VM.
last: Option<(u64, bool, bool)>,
/// The last corpse-range verdict announced to the UI (`None` = nothing announced): the
/// CORPSE_IN_RANGE / CORPSE_OUT_OF_RANGE / CORPSE_IN_INSTANCE edge memory (decision 0308 §5).
corpse_range: Option<CorpseRange>,
@@ -196,20 +215,19 @@ fn feed_death(
sickness_duration: sickness_duration(store.0.unit_level().unwrap_or(0)),
});
// ── The state-machine edges (classic event semantics — module doc) ─────────────────────────
let prev = feed.last.filter(|&(g, ..)| g == guid);
feed.last = Some((guid, dead, ghost));
// ── The WORLD edges (the body's own state machine): the release-window anchor and the
// corpse asks. These run off `mirror`, which survives a VM replacement — a `/reload` while
// dead must neither restart the six minutes nor re-query the corpse (the cache has it).
let prev = feed.mirror.filter(|&(g, ..)| g == guid);
feed.mirror = Some((guid, dead, ghost));
match (prev, dead, ghost) {
// Alive → dead (and the login-while-dead first snapshot): the DEATH popup's trigger.
// Alive → dead (and the login-while-dead first snapshot): arm the release window.
(Some((_, false, false)) | None, true, false) => {
feed.died_at = Some(now);
script.fire_event("PLAYER_DEAD", vec![]);
}
// Dead-unreleased → ghost (the release landed: ghost aura + graveyard teleport), or →
// alive (a pre-release res): both are the classic PLAYER_ALIVE.
// Dead-unreleased → ghost (the release landed), or → alive (a pre-release res).
(Some((_, true, false)), d, g) if g || !d => {
feed.died_at = None;
script.fire_event("PLAYER_ALIVE", vec![]);
if g {
// Now a ghost: ask where the corpse is (feeds the slice-2 markers + range gate).
let _ = net.0.send(ClientCommand::CorpseQuery);
@@ -218,7 +236,6 @@ fn feed_death(
// Ghost → alive (reclaim / spirit healer / accepted res).
(Some((_, _, true)), false, false) => {
feed.died_at = None;
script.fire_event("PLAYER_UNGHOST", vec![]);
// Re-ask where the corpse is — the answer is the authoritative NOT-FOUND that drops
// the map markers. The server's own "corpse gone" push is LOOTER-gated (vmangos
// Map.cpp:3617-3629: the unprompted u8(0) goes out only when a PvP looter exists), so
@@ -228,16 +245,36 @@ fn feed_death(
// Director-reported: the map tombstone survived a spirit-healer res without this.
let _ = net.0.send(ClientCommand::CorpseQuery);
}
// Logging in already a ghost: no edge event (nothing was open), just the corpse ask.
// Logging in already a ghost: no edge, just the corpse ask.
(None, _, true) => {
let _ = net.0.send(ClientCommand::CorpseQuery);
}
_ => {}
}
// ── The VM edges: the classic events, fired off what THIS VM has heard (module doc). A
// fresh VM's memo is empty, so a reload-while-dead re-fires PLAYER_DEAD to the rebuilt
// frame tree — the same first-snapshot-counts-as-edge that brings the popup up at a
// login-while-dead, which is our design's own posture for both (decision 1291).
let memo = feed.vm.get(&script);
let prev = memo.last.filter(|&(g, ..)| g == guid);
memo.last = Some((guid, dead, ghost));
match (prev, dead, ghost) {
(Some((_, false, false)) | None, true, false) => {
script.fire_event("PLAYER_DEAD", vec![]);
}
(Some((_, true, false)), d, g) if g || !d => {
script.fire_event("PLAYER_ALIVE", vec![]);
}
(Some((_, _, true)), false, false) => {
script.fire_event("PLAYER_UNGHOST", vec![]);
}
_ => {}
}
// ── The offer/confirm announcements (edge-fired, name-gated) ───────────────────────────────
match &death_net.resurrect {
Some(offer) if !feed.offer_announced => {
Some(offer) if !memo.offer_announced => {
// A player caster's wire name is empty — resolve through the ask-once name cache and
// hold the popup until it lands (the ref popup formats "%s wants to resurrect you").
let name = if offer.name.is_empty() {
@@ -246,18 +283,18 @@ fn feed_death(
Some(offer.name.clone())
};
if let Some(name) = name {
feed.offer_announced = true;
memo.offer_announced = true;
script.fire_event("RESURRECT_REQUEST", vec![ScriptValue::Str(name)]);
}
}
None => feed.offer_announced = false,
None => memo.offer_announced = false,
_ => {}
}
// The confirm is message-fired, not state-edged (decision 1068): each SMSG bumps the
// generation, so asking the healer again after a Cancel brings the dialog back.
if death_net.spirit_healer.is_some() && feed.confirm_generation != death_net.confirm_generation
if death_net.spirit_healer.is_some() && memo.confirm_generation != death_net.confirm_generation
{
feed.confirm_generation = death_net.confirm_generation;
memo.confirm_generation = death_net.confirm_generation;
script.fire_event("CONFIRM_XP_LOSS", vec![]);
}
@@ -286,22 +323,22 @@ fn feed_death(
} else {
None
};
if feed.reclaim_generation != death_net.reclaim_generation {
feed.reclaim_generation = death_net.reclaim_generation;
feed.corpse_range = None; // re-announce whatever holds now (the client's 0x269 re-fire)
if memo.reclaim_generation != death_net.reclaim_generation {
memo.reclaim_generation = death_net.reclaim_generation;
memo.corpse_range = None; // re-announce whatever holds now (the client's 0x269 re-fire)
}
if range != feed.corpse_range {
if range != memo.corpse_range {
match range {
Some(CorpseRange::In) => script.fire_event("CORPSE_IN_RANGE", vec![]),
Some(CorpseRange::InInstance) => script.fire_event("CORPSE_IN_INSTANCE", vec![]),
// Leaving range — and the corpse/ghost state ENDING while a range dialog could be
// up — both land on the same hide event (the ref's OUT arm hides all three).
Some(CorpseRange::Out) | None if feed.corpse_range.is_some() => {
Some(CorpseRange::Out) | None if memo.corpse_range.is_some() => {
script.fire_event("CORPSE_OUT_OF_RANGE", vec![]);
}
_ => {}
}
feed.corpse_range = range;
memo.corpse_range = range;
}
}
@@ -833,7 +833,10 @@ pub(super) fn attach_entity_visuals(
benilla_world::ribbons::RibbonSeq::Host(entity),
// The unit's own render alpha gates its trail (0827).
Some(entity),
None, // a streamed unit belongs to no building's doodad set
// No fade sphere: a streamed unit is not a placed model — its
// population is bounded by server visibility, and its trail by the
// unit's own render alpha one line up.
None,
);
}
}
@@ -665,7 +665,9 @@ fn spawn_slot(
// Its own model instance — chained to the wearer above — so an enchant streamer is
// gone with the avatar in first person and absent until the body is shown (0827/0833).
Some(root),
None, // a carried item belongs to no building's doodad set
// No fade sphere: a carried item is not a placed model — it rides its wearer's
// residency, and its streamer the wearer's render alpha one line up.
None,
);
}
debug!(
@@ -466,7 +466,9 @@ pub(crate) fn attach_effect_visuals(
// This instance's own model alpha, chained to its host (0827/0833): a standalone
// instance has none above it and draws exactly as before.
Some(root),
None, // an effect instance belongs to no building's doodad set
// No fade sphere: an effect instance is not a placed model — it lives for its
// clip and is gated by its own model alpha one line up.
None,
);
}
true
+13 -5
View File
@@ -370,11 +370,19 @@ pub(super) fn spawn_wmo_gameobject_props(
benilla_world::ribbons::RibbonSeq::Host(entity),
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
None,
// A GameObject's props ride the GameObject, not a portal-culled placement:
// this WMO is the boat, and nothing floods it. (Its off-map hide reaches
// the trail no better than before — a `Visibility`-hidden transport still
// trails. No 1.12 transport prop authors a ribbon, so nothing renders it
// today; it is named in decision 1289's residuals rather than guessed at.)
// No fade sphere: a GameObject's props ride the GameObject, not a
// baked world placement — that gate's centre is a fixed world point and a
// mover would drag away from it (the same reason the emitters beside them
// take none, stated at the top of this file), and nothing floods this WMO
// to give it rooms. The far-clip wall still applies: `simulate_ribbons`
// bounds a fade-less trail at the wall exactly as `simulate_particles`
// bounds a fade-less emitter, which is what a map-wide-streamed transport
// five kilometres away needs (decision 0678).
//
// Still open, and named rather than guessed at: a `Visibility`-hidden
// transport trails. The trail reads its owner's render alpha, not its
// `Visibility`, and the off-map hide writes the latter. No 1.12 transport
// prop authors a ribbon, so nothing renders it today.
None,
)
.is_some()
+36 -20
View File
@@ -196,11 +196,26 @@ fn reconcile(cache: &mut Vec<CachedAura>, live: &[UnitAuraSlot], now: f64) {
/// a `UNIT_AURA` trigger.
type AuraProjection = (u32, u8, bool, Option<String>);
/// The feed's edge-detection memory: per token, the projection of the list last pushed. A resource
/// rather than a `Local` (the [`crate::ui_unit::UnitFeedState`] pattern) because the session-end
/// teardown resets it from its own system — see [`end_session_aura_state`].
/// The feed's edge-detection memory: per token, the projection of the list last pushed (the
/// [`crate::ui_unit::UnitFeedState`] resource shape).
///
/// The edge keys live behind a [`crate::ui_script::VmMemo`]: what we last told the VM is a claim
/// about THAT VM (1290), so against a `/reload`'s replacement (1291) — which swaps the VM while
/// every aura stays live — the memo reads fresh and `UNIT_AURA` re-fires for the new frames. That
/// key is also what retired the explicit reset [`end_session_aura_state`] used to run: the memo
/// dies with the VM, while that system keeps its real job, dropping the server-side aura state.
#[derive(Resource, Default)]
struct AuraFeedMemory {
/// What we last told the VM — dies with the VM it was told to.
vm: crate::ui_script::VmMemo<AuraFeedMemo>,
/// The Bevy-clock instant [`trace_timers`] last printed (`BENILLA_AURA_TRACE`). Host
/// diagnostics, not VM memory — the trace cadence outlives any one VM, deliberately.
traced_at: f64,
}
/// The per-VM half of [`AuraFeedMemory`] — the `UNIT_AURA` edge keys.
#[derive(Default)]
struct AuraFeedMemo {
present: bool,
last: Vec<AuraProjection>,
/// The tracking spell last pushed ([`tracking_state_of`]) — part of the player half's change
@@ -214,8 +229,6 @@ struct AuraFeedMemory {
/// The pet half, same shape and same reason (decision 0990): a stable swap between two pets
/// carrying identical auras must still re-fire, so the guid joins the key.
pet_last: Option<(u64, Vec<AuraProjection>)>,
/// The Bevy-clock instant [`trace_timers`] last printed (`BENILLA_AURA_TRACE`).
traced_at: f64,
}
/// The `BENILLA_AURA_TRACE` period in seconds, or `None` when the trace is off. A set-but-unparsable
@@ -623,14 +636,19 @@ fn feed_auras(
}
}
// The VM half — resolved against THIS VM, so a `/reload`'s replacement (1291) reads a fresh
// memo and every edge below re-fires for it. Taken after the `traced_at` use above: the trace
// cadence is host diagnostics and must not expire with the VM.
let memo = mem.vm.get(&script);
// Edge-trigger UNIT_AURA on a discrete change (the reference's PLAYER_AURAS_CHANGED), never on
// the countdown alone — that's a per-frame OnUpdate on the button, not an event. The tracking
// spell joins the key: a tracking *switch* changes no visible list, only the minimap icon.
let projection = projection_of(&list);
let changed = !mem.present || projection != mem.last || tracking_spell != mem.tracking_last;
mem.last = projection;
mem.tracking_last = tracking_spell;
mem.present = true;
let changed = !memo.present || projection != memo.last || tracking_spell != memo.tracking_last;
memo.last = projection;
memo.tracking_last = tracking_spell;
memo.present = true;
// Debug affordance (`BENILLA_AURA_DUMP=1`): when the visible set changes, log every slot the
// player's bar will draw — raw slot, spell id, resolved name, class, and the flags nibble. The
@@ -715,8 +733,8 @@ fn feed_auras(
.guid
.zip(target_list.as_deref())
.map(|(guid, l)| (guid, projection_of(l)));
let target_changed = target_cur.is_some() && target_cur != mem.target_last;
mem.target_last = target_cur;
let target_changed = target_cur.is_some() && target_cur != memo.target_last;
memo.target_last = target_cur;
// The BENILLA_AURA_DUMP affordance's target half: what the target rows will draw, on change.
if target_changed && std::env::var_os("BENILLA_AURA_DUMP").is_some() {
@@ -737,8 +755,8 @@ fn feed_auras(
.then_some(pet_list.as_deref())
.flatten()
.map(|l| (pet_guid, projection_of(l)));
let pet_changed = pet_cur.is_some() && pet_cur != mem.pet_last;
mem.pet_last = pet_cur;
let pet_changed = pet_cur.is_some() && pet_cur != memo.pet_last;
memo.pet_last = pet_cur;
script.set_auras("player", Some(list));
// Clearing the token isn't a UNIT_* event (the frame reacts to PLAYER_TARGET_CHANGED, and the
@@ -808,7 +826,6 @@ fn end_session_aura_state(
script: Option<NonSendMut<UiScript>>,
mut durations: ResMut<AuraDurations>,
mut cache: ResMut<PlayerAuraCache>,
mut mem: ResMut<AuraFeedMemory>,
) {
if let Some(mut script) = script {
script.set_auras("player", None);
@@ -817,7 +834,8 @@ fn end_session_aura_state(
}
cache.auras.clear();
durations.by_slot.clear();
*mem = AuraFeedMemory::default();
// No [`AuraFeedMemory`] reset here: its edge keys live behind a `VmMemo` and die with the VM
// this session's pushes went to (1290/1291) — only the server-side state above needs a hand.
}
/// Drain the spell ids `CancelUnitBuff` queued this frame and send one `CMSG_CANCEL_AURA` each. The
@@ -878,7 +896,6 @@ mod tests {
level: 60,
stacks: 1,
});
app.world_mut().resource_mut::<AuraFeedMemory>().present = true;
}
/// **Decision 0900, pinned.** A cross-map worldport despawns every tracked entity — our avatar
@@ -920,10 +937,9 @@ mod tests {
app.update();
assert!(app.world().resource::<PlayerAuraCache>().auras.is_empty());
assert!(app.world().resource::<AuraDurations>().by_slot.is_empty());
assert!(
!app.world().resource::<AuraFeedMemory>().present,
"the edge memory resets too, so the next character's first list counts as an edge"
);
// The feed's edge memory needs no teardown assertion any more: it sits behind a `VmMemo`,
// so it dies with the VM rather than on this edge — the property `ui_script::session`'s
// own tests pin (1290/1291), and one a VM-less app cannot observe.
}
fn slot(slot: u8, spell_id: u32) -> UnitAuraSlot {
+23 -8
View File
@@ -72,8 +72,19 @@ fn fixed(v: f32) -> i64 {
/// The feed's change-tracking memory (the [`crate::ui_unit::UnitFeedState`] shape): the last
/// snapshots pushed, so events fire on transitions only.
///
/// All of it is what-we-told-the-VM, so all of it lives behind a [`crate::ui_script::VmMemo`]: a
/// memory about one VM is unreadable against the next (1290), so a `/reload` (1291) — which
/// replaces the VM without despawning the avatar — re-pushes both snapshots and re-fires their
/// events exactly as a fresh login does.
#[derive(Resource, Default)]
struct CharFeedState {
vm: crate::ui_script::VmMemo<CharFeedMemo>,
}
/// The per-VM half of [`CharFeedState`] — the transition-diff bases.
#[derive(Default)]
struct CharFeedMemo {
last_stats: Option<UnitCombatStats>,
last_inv: Option<InventorySlots>,
}
@@ -808,25 +819,29 @@ fn feed_char(
// The pane's Model:SetRotation transcription owns the yaw; the booth mirrors it (0208 §5).
booth.yaw = script.paperdoll_yaw();
// Resolved against THIS VM (1290/1291): a `/reload`'s replacement reads a fresh memo, so both
// snapshots below re-push and their events re-fire for it.
let memo = feed.vm.get(&script);
let Some(store) = self_q.iter().next() else {
if feed.last_stats.is_some() || feed.last_inv.is_some() {
if memo.last_stats.is_some() || memo.last_inv.is_some() {
script.set_player_combat_stats(None);
script.set_inventory_slots(Default::default());
feed.last_stats = None;
feed.last_inv = None;
memo.last_stats = None;
memo.last_inv = None;
}
return;
};
let stats = combat_stats(store, &mut items, &commands);
if feed.last_stats.as_ref() != Some(&stats) {
if memo.last_stats.as_ref() != Some(&stats) {
// PUSH before firing: event dispatch runs the Lua handlers synchronously, so the snapshot
// must already be in the VM when they repaint (the ui_unit rule — a fire-first ordering
// paints the OLD values and, being transition-gated, never corrects itself).
let prev = feed.last_stats.take();
let prev = memo.last_stats.take();
script.set_player_combat_stats(Some(stats.clone()));
fire_stat_transitions(&mut script, "player", prev.as_ref(), &stats);
feed.last_stats = Some(stats);
memo.last_stats = Some(stats);
}
let inv = inventory_slots(
@@ -838,13 +853,13 @@ fn feed_char(
&pending,
&mut names,
);
if feed.last_inv.as_ref() != Some(&inv) {
if memo.last_inv.as_ref() != Some(&inv) {
script.set_inventory_slots(inv.clone());
script.fire_event(
"UNIT_INVENTORY_CHANGED",
vec![ScriptValue::Str("player".to_string())],
);
feed.last_inv = Some(inv);
memo.last_inv = Some(inv);
}
// `GetWeaponEnchantInfo`'s whole data path (the buff bar's TemporaryEnchantFrame row, plus 8
+3
View File
@@ -61,6 +61,9 @@ impl Plugin for UiChatPlugin {
// Push before the input pass so a line is on screen the same frame it decodes (mirrors
// the loot/merchant feeds).
.add_systems(Update, feed::feed_chat.before(UiInput))
// A fresh VM gets the joined-channel mirror re-pushed once (decision 1291) — before
// the feed, so the reload frame's first routed line already renders numbered.
.add_systems(Update, channels::seed_channels.before(feed::feed_chat))
// RequestTimePlayed() -> CMSG_PLAYED_TIME, and SMSG_PLAYED_TIME -> TIME_PLAYED_MSG.
// Beside the chat feed because /played is a chat command and the answer prints there
// too; before the input pass for the same reason feed_chat is.
@@ -136,6 +136,26 @@ fn end_channel_session(
}
}
/// Seed a fresh VM's joined-channel mirror (decision 1291). The mirror is otherwise pushed only
/// on the join/leave edges ([`super::feed`]'s YOU_JOINED / YOU_LEFT arms), and a `/reload`
/// replaces the VM *between* edges — `ChannelState` (server truth) survives, but the new VM's
/// mirror would stay empty: `GetChannelName`/`GetChannelList` answer nothing, `/1`-`/9` routing
/// is dead, and every channel line renders unnumbered until the player happens to join or leave
/// something. A login goes through [`end_session_channels`] + the auto-join walk instead, where
/// this claim pushes the just-cleared (empty) list — a no-op by construction.
pub(super) fn seed_channels(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
channels: Res<ChannelState>,
mut seeded: Local<crate::ui_script::VmMemo<bool>>,
) {
let Some(mut script) = script else {
return;
};
if seeded.claim(&script) {
script.set_joined_channels(channels.joined.clone());
}
}
/// The session-end edge: a confirmed `/logout` back to the glue layer (`OnExit(InWorld)`), which is
/// the character switch the director's screenshot caught.
pub(super) fn end_session_channels(
+45 -15
View File
@@ -82,6 +82,17 @@ pub(crate) enum SlashIndex {
MacroUi,
/// `/macrohelp` — the reference's own five-line help text.
MacroHelp,
/// `/console <cmd>` — the reference's `SlashCmdList["CONSOLE"]` pipes into the engine's
/// debug-console command table. Ours implements the one console command this client has:
/// `reloadUI` (the reference's `"reloadUI" 0x82ea38` → `CCommand::ReloadUI 0x4035f0` →
/// `0x491380`, the same flag `ReloadUI()` sets). Anything else answers a plain system line
/// rather than silently doing nothing.
Console,
/// `/reload` — **benilla's own addition** (decision 1291): 1.12 ships `ReloadUI()` and
/// `/console reloadUI` but no slash alias for it (that arrived in later clients — the shipped
/// `GlobalStrings.lua` has no `SLASH_RELOADUI`), so this alias is a literal in [`Self::build`]
/// rather than data read off the chain, exactly like the ESC-menu AddOns window is ours (1197).
ReloadUi,
}
impl SlashIndex {
@@ -122,13 +133,17 @@ impl SlashIndex {
Self::Cast => "CAST",
Self::MacroUi => "MACRO",
Self::MacroHelp => "MACROHELP",
Self::Console => "CONSOLE",
// No shipped alias string exists under this key (the walk finds nothing); the `/reload`
// alias is inserted as a literal in `build` — see the variant's doc.
Self::ReloadUi => "RELOADUI",
}
}
/// Every registered index — the registry proper. A reference command NOT in this list resolves
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
/// (better than a registered handler that silently does nothing).
const ALL: [Self; 34] = [
const ALL: [Self; 36] = [
Self::Reply,
Self::Join,
Self::Leave,
@@ -163,6 +178,8 @@ impl SlashIndex {
Self::Cast,
Self::MacroUi,
Self::MacroHelp,
Self::Console,
Self::ReloadUi,
];
}
@@ -218,8 +235,8 @@ pub(crate) struct SlashCommands {
by_alias: HashMap<String, Command>,
/// How many aliases each source contributed — the boot line that makes a broken table (an
/// unreadable `GlobalStrings.lua`, a missing token table) visible immediately instead of at
/// the first command someone types.
counts: (usize, usize, usize),
/// the first command someone types. `(slash, emote, benilla additions, dev instruments)`.
counts: (usize, usize, usize, usize),
}
impl SlashCommands {
@@ -270,7 +287,19 @@ impl SlashCommands {
}
let emote_aliases = by_alias.len() - slash_aliases;
// 3 · benilla's instruments, last so they can never shadow a shipped command.
// 3 · benilla's own player-facing additions — literals, because they are OURS and no
// shipped string carries them (each variant's doc records the why). NOT gated on dev
// affordances: `/reload` belongs to a player build as much as the ESC-menu AddOns window
// does (1197, 1291). Inserted after the walks so a shipped alias could never be shadowed
// even if a later client's strings arrived on the chain.
insert(
&mut by_alias,
"reload",
Command::Slash(SlashIndex::ReloadUi),
);
let added_aliases = by_alias.len() - slash_aliases - emote_aliases;
// 4 · benilla's instruments, last so they can never shadow a shipped command.
// benilla's own instruments — `/castvis`, `/partytest`, `/chattest`, `/shot`, `/liquid`,
// `/reaction`. A player build claims none of the aliases, so typing one falls through to
// the reference's "unknown command" exactly as it should (decision 1179). They are gated
@@ -287,18 +316,20 @@ impl SlashCommands {
Self {
by_alias,
counts: (slash_aliases, emote_aliases, dev_aliases),
counts: (slash_aliases, emote_aliases, added_aliases, dev_aliases),
}
}
/// `(slash aliases, emote aliases, dev aliases)` — how many DISTINCT commands each source made
/// reachable (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2` are both `"/sit"`). The
/// boot report.
/// `(slash aliases, emote aliases, benilla additions, dev aliases)` — how many DISTINCT
/// commands each source made reachable (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2`
/// are both `"/sit"`). The boot report.
///
/// The third number is the seam, **made observable** (decision 1179): benilla's own instrument
/// The last number is the seam, **made observable** (decision 1179): benilla's own instrument
/// commands are gated on `run_mode::dev_affordances()`, and a gate nobody can see the effect of
/// is a gate nobody checks. A player build must print `0`, and one line of its own log says so.
pub(super) fn counts(&self) -> (usize, usize, usize) {
/// The third — the player-facing additions (`/reload`, 1291) — is deliberately separate from
/// both: present in every build, and never passed off as a shipped command.
pub(super) fn counts(&self) -> (usize, usize, usize, usize) {
self.counts
}
}
@@ -330,13 +361,12 @@ pub(crate) fn build_slash_commands(
|name| globals.get::<String>(name).ok().filter(|s| !s.is_empty()),
|token| emotes.text_id(token),
);
let (slash, emote, dev) = table.counts();
// The "it loaded" signal: the shipped 1.12 data yields 55 distinct aliases across the
// registered indices and 225 emote commands (pinned by `real_alias_table_resolves_the_shipped_
// commands`).
let (slash, emote, added, dev) = table.counts();
// The "it loaded" signal: the shipped 1.12 data yields the pinned alias counts (see
// `real_alias_table_resolves_the_shipped_commands`), plus benilla's own additions (1291).
info!(
"chat: slash table — {slash} command aliases, {emote} emote aliases, \
{dev} instrument aliases"
{added} benilla additions, {dev} instrument aliases"
);
if emote == 0 {
error!("chat: NO emote aliases — every /wave-style command is dead (GlobalStrings?)");
@@ -758,6 +758,24 @@ pub(super) fn drain_chat_input(
ParsedChat::Quit => {
script.queue_session_request(benilla_ui::script::SessionRequest::Quit)
}
// `/reload` and `/console reloadUI` — the same deferred rebuild `ReloadUI()` queues
// (decision 1291), through the same session seam as the exit verbs above.
ParsedChat::ReloadUi => {
script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi)
}
ParsedChat::ConsoleUnknown { cmd } => {
let text = if cmd.is_empty() {
"console: no command given (this client implements: reloadUI)".to_string()
} else {
format!(
"console: '{cmd}' is not implemented (this client implements: reloadUI)"
)
};
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
text,
));
}
// The reference's own handler body, run in the VM (the 0668 posture) — `/trade`,
// `/inspect`, the loot-method trio, and `/script`'s raw chunk.
ParsedChat::Lua { body } => {
@@ -181,6 +181,15 @@ pub(in crate::ui_chat) enum ParsedChat {
/// straight out of the shipped `GlobalStrings.lua` (decision 0983). Resolved in the drain,
/// which holds the VM, so the text can never go stale against the install.
MacroHelp,
/// `/reload`, `/console reloadUI`, or `ReloadUI()` typed through `/script` — tear the UI
/// session down and build a new one without leaving the world (decision 1291). Queued on the
/// session seam like [`ParsedChat::Logout`]; [`crate::ui_script::run_pending_reload`] runs it
/// at the top of the next frame.
ReloadUi,
/// A `/console` command that is not `reloadUI` — the engine console this client does not
/// have. Answered with a system line naming what was asked, because a claimed alias that
/// silently drops its argument reads as a hang.
ConsoleUnknown { cmd: String },
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
Unknown,
}
@@ -387,6 +396,16 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
body: "ShowMacroFrame()".into(),
},
S::MacroHelp => ParsedChat::MacroHelp,
// `/reload` (ours, 1291) and the reference's `/console reloadUI` are the same rebuild.
// The console command match is case-insensitive like the engine's console; `0x4035f0`
// reads no arguments, so anything after `reloadUI` is ignored rather than an error.
S::ReloadUi => ParsedChat::ReloadUi,
S::Console => match args.split_whitespace().next() {
Some(cmd) if cmd.eq_ignore_ascii_case("reloadui") => ParsedChat::ReloadUi,
_ => ParsedChat::ConsoleUnknown {
cmd: args.to_string(),
},
},
// `/script` = the ref's `RunScript(msg)`: the typed text IS the chunk, un-escaped.
S::Script => {
if args.is_empty() {
+11 -6
View File
@@ -1345,12 +1345,17 @@ fn real_alias_table_resolves_the_shipped_commands() {
assert_eq!(parse_line("/macrohelp"), ParsedChat::MacroHelp);
// The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote
// commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2`
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **67 distinct
// aliases** across the 34 registered `SlashCmdList` indices (0886 added TARGET's `/target`
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **68 distinct
// aliases** across the 36 registered `SlashCmdList` indices (0886 added TARGET's `/target`
// `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`;
// 0983 added CAST's `/cast` `/spell`, MACRO's `/macro` `/m`, and MACROHELP's `/macrohelp`).
// 0983 added CAST's `/cast` `/spell`, MACRO's `/macro` `/m`, and MACROHELP's `/macrohelp`;
// 1291 added CONSOLE's `/console` — one distinct alias, SLASH_CONSOLE1 and 2 are both the
// same string).
//
// The third number is the **seam** (decision 1179): benilla's own instrument commands
// The third number is benilla's own player-facing additions (1291): `/reload` — present in
// every build, deliberately counted apart from the shipped surface so the seam stays visible.
//
// The fourth is the instrument **seam** (decision 1179): benilla's own instrument commands
// (`/castvis` `/chattest` `/partytest` `/shot` `/liquid` `/reaction` `/react` — 7 aliases over 6
// commands) are registered only when `run_mode::dev_affordances()`, so a player build claims
// none of them and `/partytest` falls through to the reference's "unknown command". Asserted
@@ -1362,8 +1367,8 @@ fn real_alias_table_resolves_the_shipped_commands() {
};
assert_eq!(
table.counts(),
(67, 225, instruments),
"(slash, emote, instrument) aliases"
(68, 225, 1, instruments),
"(slash, emote, benilla addition, instrument) aliases"
);
}
+16 -3
View File
@@ -57,8 +57,19 @@ use crate::ui_script::UiInput;
/// What the VM has last been told, so the two events fire exactly on transitions and never once a
/// frame. Mirrors [`FollowState::guid`]; `None` means the VM believes no follow is running.
///
/// "What THIS VM has been told" is exactly what a [`crate::ui_script::VmMemo`] keys (1290): a
/// `/reload` (1291) replaces the VM mid-follow without touching [`FollowState`], and the fresh
/// memo's `None` makes the next frame re-fire `AUTOFOLLOW_BEGIN` — the replacement VM never heard
/// it, so its status line has to be told again.
#[derive(Resource, Default)]
pub(crate) struct FollowFeed {
vm: crate::ui_script::VmMemo<FollowFeedMemo>,
}
/// The per-VM half of [`FollowFeed`] — the one transition base.
#[derive(Default)]
struct FollowFeedMemo {
told: Option<u64>,
}
@@ -71,7 +82,9 @@ fn feed_follow(
let Some(mut script) = script else {
return;
};
match (follow.guid, feed.told) {
// Resolved against THIS VM (1290/1291): a replacement VM reads `told: None` and gets its BEGIN.
let memo = feed.vm.get(&script);
match (follow.guid, memo.told) {
// Started, or switched subject. A switch emits END first, because the reference's arm
// routine calls the canceller before arming (see the module header).
(Some(guid), told) if told != Some(guid) => {
@@ -82,13 +95,13 @@ fn feed_follow(
"AUTOFOLLOW_BEGIN",
vec![ScriptValue::Str(follow.name.clone())],
);
feed.told = Some(guid);
memo.told = Some(guid);
}
// Stopped — by arrival at an unresolvable followee, by the turn-away cancel, or by any of
// the input cancels. END carries no argument: the frame reuses the name BEGIN latched.
(None, Some(_)) => {
script.fire_event("AUTOFOLLOW_END", vec![]);
feed.told = None;
memo.told = None;
}
_ => {}
}
+20 -7
View File
@@ -83,8 +83,18 @@ pub(crate) struct InspectTarget {
/// The last pushed view + the last level seen, so the feed pushes and fires only on real change
/// (the `ui_char`/`ui_unit` discipline).
///
/// Both are claims about what THIS VM holds, so both sit behind a [`crate::ui_script::VmMemo`]
/// (1290): against a `/reload`'s replacement VM (1291) the memo reads fresh, and the view is
/// re-pushed and its events re-fired for a window the new VM has yet to hear about.
#[derive(Resource, Default)]
struct InspectFeedState {
vm: crate::ui_script::VmMemo<InspectFeedMemo>,
}
/// The per-VM half of [`InspectFeedState`] — the change bases.
#[derive(Default)]
struct InspectFeedMemo {
last: Option<InspectView>,
last_level: Option<u32>,
}
@@ -176,6 +186,9 @@ fn feed_inspect(
let Some(mut script) = script else {
return;
};
// Resolved against THIS VM (1290/1291): a `/reload` keeps the latched token, and the fresh
// memo re-pushes the view into the VM that replaced the one which last saw it.
let memo = feed.vm.get(&script);
// The reach map: for every popup token that resolves to a live, inspectable player, its squared
// distance from us. A token is entered ONLY if it passes the two non-distance refusals vmangos
@@ -239,10 +252,10 @@ fn feed_inspect(
let Some((token, guid, entity)) = resolved else {
// Nothing inspected (or the target isn't streamed): clear the view and empty the booth.
if feed.last.is_some() {
if memo.last.is_some() {
script.set_inspect(None);
feed.last = None;
feed.last_level = None;
memo.last = None;
memo.last_level = None;
}
booth.unit = None;
return;
@@ -270,9 +283,9 @@ fn feed_inspect(
guid,
slots,
};
if feed.last.as_ref() != Some(&view) {
if memo.last.as_ref() != Some(&view) {
script.set_inspect(Some(view.clone()));
feed.last = Some(view);
memo.last = Some(view);
// The ref's InspectPaperDollItemSlotButton_OnEvent listens for exactly this, filtered on
// arg1 == the inspected unit.
script.fire_event(
@@ -281,8 +294,8 @@ fn feed_inspect(
);
}
let level = store.0.unit_level();
if feed.last_level != level {
feed.last_level = level;
if memo.last_level != level {
memo.last_level = level;
script.fire_event("UNIT_LEVEL", vec![ScriptValue::Str(token)]);
}
}
+19 -7
View File
@@ -101,6 +101,17 @@ pub(crate) struct ReadSession {
/// frame until ready, and re-using the *same* object closes the reader (the reference's toggle).
pub(crate) object_guid: u64,
pub(crate) source: ReadSource,
/// What the live VM has been told about this session — keyed on the VM (decisions
/// 1290/1291), because the session is world state (the letter is still open) while the
/// fires are VM state: a `/reload` with a book up replaces the frame tree, and the fresh
/// one needs the `ITEM_TEXT_BEGIN` and repaint the old one already consumed. Before this,
/// the reader never came back after a reload while the host still believed it was open.
told: crate::ui_script::VmMemo<ItemTextTold>,
}
/// [`ReadSession::told`]'s payload — the per-VM fire latches.
#[derive(Default)]
struct ItemTextTold {
/// `ITEM_TEXT_BEGIN` fired (the reference fires it once per open, before the text lands).
begun: bool,
/// `ITEM_TEXT_READY` fired — the session is fully painted.
@@ -160,8 +171,7 @@ impl ItemTextOpen {
self.pending = Some(ReadSession {
object_guid,
source,
begun: false,
ready: false,
told: Default::default(),
});
}
}
@@ -257,7 +267,7 @@ fn feed_item_text(
let Some(sess) = open.pending.as_mut() else {
return;
};
if sess.ready {
if sess.told.get(&script).ready {
return;
}
let Some(readable) = readable(
@@ -281,7 +291,7 @@ fn feed_item_text(
return;
}
if !sess.begun {
if !sess.told.get(&script).begun {
script.set_item_text(Some(ItemTextState {
item: readable.title.clone(),
creator: None,
@@ -291,7 +301,7 @@ fn feed_item_text(
material: readable.material.clone(),
}));
script.fire_event("ITEM_TEXT_BEGIN", vec![]);
sess.begun = true;
sess.told.get(&script).begun = true;
}
let (creator, text, page, has_next) = match &mut sess.source {
@@ -358,7 +368,7 @@ fn feed_item_text(
material: readable.material,
}));
script.fire_event("ITEM_TEXT_READY", vec![]);
sess.ready = true;
sess.told.get(&script).ready = true;
}
/// `PageTextMaterial.dbc` as a resource (decision 1105) — the reader frame's material basename,
@@ -387,7 +397,9 @@ fn drain_item_text(
continue; // a letter has no pages; its buttons never show
};
if turn_page(visited, delta, |id| pages.pages.get(&id).map(|p| p.next)) {
sess.ready = false; // repaint on the next feed, without re-firing BEGIN
// Repaint on the next feed, without re-firing BEGIN (within this VM; a fresh VM
// re-begins regardless, which is the reload repaint).
sess.told.get(&script).ready = false;
}
}
if script.take_item_text_close() && open.pending.take().is_some() {
+4
View File
@@ -130,6 +130,7 @@ fn drain_logout(
self_guid: Res<SelfGuid>,
commands: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
mut reload: ResMut<crate::ui_script::ReloadUiPending>,
) {
let Some(mut script) = script else {
return;
@@ -170,6 +171,9 @@ fn drain_logout(
info!("logout: force quit");
exit.write(AppExit::Success);
}
// Not this module's business beyond routing: the rebuild itself is
// [`crate::ui_script::run_pending_reload`]'s, at the top of the next frame.
SessionRequest::ReloadUi => reload.0 = true,
}
}
}
@@ -27,7 +27,7 @@ use crate::bindings::commands::SPECS;
/// The page's real neighbourhood, in the manifest's own order, with the registry seeded the
/// way the app seeds it (`crate::bindings::seed_bindings` — registration before any show).
fn harness() -> UiScript {
pub(crate) fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
let cmds: Vec<KeybindCommand> = SPECS
.iter()
@@ -78,7 +78,7 @@ fn harness() -> UiScript {
}
/// Open the options window on the Keybindings page.
fn on_page(s: &mut UiScript) {
pub(crate) fn on_page(s: &mut UiScript) {
s.run(r#"ShowUIPanel(OptionsFrame); OptionsFrame_SelectCategory("Keybindings")"#)
.unwrap();
assert!(s.errors().is_empty(), "on page: {:?}", s.errors());
+64 -1
View File
@@ -278,6 +278,15 @@ impl Plugin for UiScriptPlugin {
end_ui_session,
)
.add_systems(Update, shutdown_on_exit)
// A queued `ReloadUI()` runs in `PreUpdate` — one whole frame after the drain that
// queued it (the reference's own deferral, `0x495590`), and BEFORE every `Update`
// system, so no per-VM seed or feed can run against the dying VM in the reload frame
// and then leave the new one unseeded until the next. In `Update` the exclusive
// system would float: the scheduler could place it between `sync_cvars` and
// `save_config`, discarding a dirty CVar edit, or after `seed_bindings`, giving one
// whole frame with an empty binding table. See [`run_pending_reload`].
.init_resource::<ReloadUiPending>()
.add_systems(PreUpdate, run_pending_reload)
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
// handler reads this frame's number rather than the previous one's.
.add_systems(Update, feed_framerate.before(UiInput))
@@ -607,6 +616,11 @@ pub(crate) fn end_ui_session(world: &mut World) {
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
shutdown_ui_state(&mut script, identity.as_ref());
}
// The CVar bridge (decision 1291): the dying VM's table folds into the persist state — after
// the shutdown events above (a `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference
// that lands in an engine-side store that survives), before the VM is replaced. The next
// VM's registration seeds from what this writes ([`crate::cvars`]'s saved base).
crate::cvars::fold_dying_vm_cvars(world);
world.insert_resource(AddOnIdentity(None));
// **Everything the host is holding that came OUT of the dying VM goes with it.** A change memo
@@ -638,6 +652,52 @@ pub(crate) fn end_ui_session(world: &mut World) {
install_boot_vm(world);
}
/// A `ReloadUI()` waiting to run — set when [`crate::ui_logout`] drains
/// [`benilla_ui::script::SessionRequest::ReloadUi`], consumed by [`run_pending_reload`] at the
/// top of the next frame's `Update`.
///
/// A flag rather than an immediate call for the reference's own reason (`ds:0xb4b3f4`, its only
/// writer `0x491380` and its only reader the per-frame callback `0x495590`): the VM that queued
/// the request must not be mid-call when it is destroyed. Our drain already runs outside any VM
/// dispatch, but the flag keeps the whole rebuild at one point in the frame — before the input
/// pass — instead of wherever the drain happens to sit.
#[derive(Resource, Default)]
pub(crate) struct ReloadUiPending(pub(crate) bool);
/// Run a pending `ReloadUI()`: the reference's teardown/rebuild pair (`0x495664 call 0x490bd0`,
/// `0x495669 call 0x48fbf0`), which for us is [`end_ui_session`] then
/// [`load_ingame_ui_on_world_entry`] — the same two functions the logout/login edges run, called
/// back to back without leaving the world (decision 1291).
///
/// Everything that makes a login correct makes the reload correct **by construction**: the
/// shutdown tail fires `PLAYER_LEAVING_WORLD`/`PLAYER_LOGOUT` and writes the four files (so a
/// `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it), the
/// rebuild is a real login's load (fresh file scope, saved variables, `VARIABLES_LOADED`,
/// `PLAYER_LOGIN`), and every host memory keyed on the VM's identity ([`VmMemo`], decision 1290)
/// expires with the old session id. `PLAYER_ENTERING_WORLD` refires from [`crate::ui_unit`]'s
/// feed once it notices the new VM, with the self descriptor already present — the reference's
/// own ordering, where the event follows the rebuild.
///
/// **In-world only.** At the glue there is no in-game UI to rebuild and no identity to load
/// addons under; the reference's own gate (`0x494a50(0xa)`) refuses there too. Dropped with a log
/// line rather than deferred — a reload asked for at the character screen answers nothing.
pub(crate) fn run_pending_reload(world: &mut World) {
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
return;
}
let in_world = *world
.resource::<State<crate::char_select::ClientState>>()
.get()
== crate::char_select::ClientState::InWorld;
if !in_world {
info!("ui_script: ReloadUI outside the world — dropped");
return;
}
info!("ui_script: ReloadUI — ending the UI session and building a new one");
end_ui_session(world);
load_ingame_ui_on_world_entry(world);
}
/// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather
/// than a state edge because a quit from in-world never leaves `InWorld`.
fn shutdown_on_exit(
@@ -1112,8 +1172,11 @@ mod game_menu_tests;
#[cfg(test)]
mod macro_tests;
// `pub(crate)` for its `harness`/`on_page` alone: the bindings dispatch tests drive the real
// Keybindings page through the real input systems, and building the page twice would let the two
// copies drift.
#[cfg(test)]
mod keybindings_tests;
pub(crate) mod keybindings_tests;
#[cfg(test)]
mod options_tests;
@@ -97,10 +97,19 @@ fn booted_world() -> World {
let mut world = World::new();
world.init_resource::<super::AddOnIdentity>();
world.init_resource::<crate::minimap::MinimapZoom>();
world.init_resource::<super::ReloadUiPending>();
super::setup_script(&mut world);
world
}
/// Queue and run a `ReloadUI()` the way the app does: the pending flag, then
/// [`super::run_pending_reload`] — which checks the client state itself, so the test states it.
fn reload(world: &mut World, state: crate::char_select::ClientState) {
world.insert_resource(State::new(state));
world.resource_mut::<super::ReloadUiPending>().0 = true;
super::run_pending_reload(world);
}
/// One login, driven exactly as the app drives it: the roster carries the pick, then the world-entry
/// edge runs.
fn log_in_as(world: &mut World, name: &str, guid: u64) {
@@ -298,3 +307,171 @@ fn logging_out_leaves_no_in_game_frames_behind() {
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
// ───────────────────────────────── ReloadUI (decision 1291) ─────────────────────────────────
/// **`ReloadUI()` is a real login run in place** — the reference's teardown/rebuild pair
/// (`0x495664`/`0x495669`), which for us is the same two edge functions the logout/login cycle
/// runs. A fresh VM, a fresh file scope, the same character, and the UI back up — without leaving
/// the world.
#[test]
fn reload_ui_is_a_fresh_login_in_place() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("reload");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
let first_session = world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("in-world VM")
.session();
reload(&mut world, crate::char_select::ClientState::InWorld);
assert_eq!(
probe_saw(&world).as_deref(),
Some("Onehunter"),
"the reloaded session ran the addon's file scope again, under the same character"
);
assert_eq!(
probe_loads(&world),
1,
"…in a FRESH VM — a reload stacked onto the live state would count 2"
);
assert_ne!(
world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("in-world VM")
.session(),
first_session,
"the VM identity changed, so every VmMemo about the old session expires (1290)"
);
assert!(
frame_exists(&world, "PlayerFrame"),
"and the in-game UI is back up"
);
assert!(
!world.resource::<super::ReloadUiPending>().0,
"the request was consumed"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **A toggle staged through the API takes effect at the reload** — the whole point of the verb.
/// `DisableAddOn` only marks the live registry; the reload's shutdown tail writes `AddOns.txt`
/// (the reference's own last write before the state dies), and the rebuild reads it back — so the
/// addon is genuinely not loaded, not hidden.
#[test]
fn a_disable_staged_in_the_session_applies_at_the_reload() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("disable");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
assert!(
probe_saw(&world).is_some(),
"the probe loaded to begin with"
);
world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("in-world VM")
.run("DisableAddOn('SwitchProbe')")
.expect("DisableAddOn");
assert!(
probe_saw(&world).is_some(),
"disabling alone changes nothing in the live session — there is no unload (1197)"
);
reload(&mut world, crate::char_select::ClientState::InWorld);
assert_eq!(
probe_saw(&world),
None,
"after the reload the disabled addon's file scope never ran"
);
let enable_file = super::addons::enable_state_path(Some(&("Realm".into(), "Onehunter".into())))
.expect("hermetic enable path");
let text = std::fs::read_to_string(&enable_file).expect("the teardown wrote AddOns.txt");
assert!(
text.contains("SwitchProbe: disabled"),
"…because the choice reached disk on the way down: {text}"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **Saved variables survive the reload** — written by the teardown (after `PLAYER_LOGOUT`),
/// restored by the rebuild after file scope, so the saved value wins over the file-scope default
/// (the byte-verified `AddOn_Load` order, 1128).
#[test]
fn saved_variables_round_trip_through_a_reload() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("saved");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("in-world VM")
.run("SwitchProbeDB.mark = 41")
.expect("mutate the saved table");
reload(&mut world, crate::char_select::ClientState::InWorld);
let mark = world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("in-world VM")
.eval::<Option<u32>>("return SwitchProbeDB and SwitchProbeDB.mark")
.expect("read back")
.unwrap_or(0);
assert_eq!(
mark, 41,
"the reload wrote the table down and the rebuild restored it OVER the file-scope default"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// A `ReloadUI()` that fires outside the world is dropped, not deferred: at the glue there is no
/// in-game UI to rebuild and no identity to load addons under (the reference's own gate,
/// `0x494a50(0xa)`, refuses there too).
#[test]
fn reload_outside_the_world_is_dropped() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("glue-reload");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
super::end_ui_session(&mut world);
reload(&mut world, crate::char_select::ClientState::CharSelect);
assert_eq!(
probe_saw(&world),
None,
"no addon loaded — the request was dropped, not run against the glue"
);
assert!(
!frame_exists(&world, "PlayerFrame"),
"and no in-game UI appeared behind the character screen"
);
assert!(
!world.resource::<super::ReloadUiPending>().0,
"the stale request is consumed, so it cannot fire on the NEXT login's first frame"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
+55 -34
View File
@@ -85,10 +85,30 @@ pub(crate) struct CombatTextEvent {
pub(crate) extra: Option<String>,
}
/// The feed's change-tracking memory: what we last told the VM, so we only fire events on transitions.
/// The feed's change-tracking memory: what we last told the VM, plus one server-side log-once.
///
/// The VM half lives behind a [`crate::ui_script::VmMemo`], **inside the resource** — the same
/// law 1290 wrote for `Local` memos, reached the way a `ResMut` system has to reach it: a memory
/// about what THIS VM was told is unreadable against the next VM, so a `/reload` (1291) — which
/// replaces the VM without despawning the world — re-fires `PLAYER_ENTERING_WORLD` and re-runs
/// every transition diff exactly as a fresh login does. Before this, every one of these fields
/// survived the reload and the new VM never heard the events (the logout path re-armed off the
/// self descriptor despawning, which a reload never does).
#[derive(Resource, Default)]
struct UnitFeedState {
/// Whether `PLAYER_ENTERING_WORLD` has been fired (once at startup).
/// What we last told the VM — dies with the VM it was told to.
vm: crate::ui_script::VmMemo<UnitFeedMemo>,
/// Whether we have already warned that our own faction template names no side (decision 0657).
/// Re-arms when a side resolves again, so a `.gm on` / `.gm off` cycle logs once each way.
/// **Server memory, not VM memory** — deliberately outside the memo: a `/reload` must not
/// re-log the GM-mode warning.
warned_sideless: bool,
}
/// The per-VM half of [`UnitFeedState`] — the event-trigger diffs.
#[derive(Default)]
struct UnitFeedMemo {
/// Whether `PLAYER_ENTERING_WORLD` has been fired (once per world entry, once per VM).
entered_world: bool,
/// Per token, the last snapshot we pushed — the per-field event triggers diff against it.
last: HashMap<String, UnitState>,
@@ -118,9 +138,6 @@ struct UnitFeedState {
/// (decision 0652). `None` until first seen: the reference reacts to a *changed*-bits mask, so
/// the descriptor that first carries the flag at login announces nothing.
pvp_desired: Option<bool>,
/// Whether we have already warned that our own faction template names no side (decision 0657).
/// Re-arms when a side resolves again, so a `.gm on` / `.gm off` cycle logs once each way.
warned_sideless: bool,
}
/// Adds the per-frame unit feed. The `Unit*` bindings themselves live in `benilla-ui`; this only
@@ -583,7 +600,7 @@ pub(crate) fn enrich_unit(
/// caller the reference has (`SlashCmdList["PVP"]`), and the only one we have.
///
/// This rides the unit feed's plugin rather than a `ui_pvp` of its own: the family's whole client
/// state is one remembered bit ([`UnitFeedState::pvp_desired`]), which lives with the feed's other
/// state is one remembered bit ([`UnitFeedMemo::pvp_desired`]), which lives with the feed's other
/// self-flag edge (`in_combat`) rather than in a plugin of its own.
fn drain_pvp_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
let Some(mut script) = script else {
@@ -794,6 +811,10 @@ fn feed_units(
}
feed.warned_sideless = sideless;
}
// The VM half — resolved against THIS VM, so a `/reload`'s fresh state reads a fresh memo and
// every event below re-fires for it (1291). Taken after the `warned_sideless` writes above:
// that field is server memory and must NOT expire with the VM.
let memo = feed.vm.get(&script);
let target = selection.target.zip(selection.guid).and_then(|(e, guid)| {
let store = stores.get(e).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
@@ -842,8 +863,8 @@ fn feed_units(
if let Some((store, _)) = self_q.iter().next() {
let xp = store.0.player_xp().unwrap_or(0);
let next = store.0.player_next_level_xp().unwrap_or(0);
if feed.last_xp != Some((xp, next)) {
feed.last_xp = Some((xp, next));
if memo.last_xp != Some((xp, next)) {
memo.last_xp = Some((xp, next));
script.set_player_xp(xp, next);
script.fire_event("PLAYER_XP_UPDATE", vec![]);
}
@@ -865,9 +886,9 @@ fn feed_units(
store.0.player_rest_state_experience().unwrap_or(0),
store.0.player_flags(),
);
if feed.last_rest != Some(rest) {
let prev = feed.last_rest;
feed.last_rest = Some(rest);
if memo.last_rest != Some(rest) {
let prev = memo.last_rest;
memo.last_rest = Some(rest);
script.set_rest_state(rest.0, rest.1, rest.2 & PLAYER_FLAGS_RESTING != 0);
if prev.map(|p| (p.0, p.1)) != Some((rest.0, rest.1)) {
script.fire_event("UPDATE_EXHAUSTION", vec![]);
@@ -904,7 +925,7 @@ fn feed_units(
// args wait for a consumer.
if let Some((store, _)) = self_q.iter().next() {
if let Some(level) = store.0.unit_level() {
let prev = feed.last_level.replace(level);
let prev = memo.last_level.replace(level);
if prev.is_some_and(|p| level != p) {
script.fire_event("PLAYER_LEVEL_UP", vec![ScriptValue::Int(i64::from(level))]);
}
@@ -929,40 +950,40 @@ fn feed_units(
// 60→1 char switch latched the max-level rail shown over a level-1 body (1106's live
// repro), and a normal→rested char switch would misfire "You feel rested." at login.
if self_pair.is_some() {
if !feed.entered_world {
if !memo.entered_world {
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
feed.entered_world = true;
memo.entered_world = true;
}
} else if feed.entered_world {
feed.entered_world = false;
feed.last_xp = None;
feed.last_rest = None;
feed.last_level = None;
feed.last_combo = None;
feed.in_combat = None;
feed.pvp_desired = None;
} else if memo.entered_world {
memo.entered_world = false;
memo.last_xp = None;
memo.last_rest = None;
memo.last_level = None;
memo.last_combo = None;
memo.in_combat = None;
memo.pvp_desired = None;
}
for (token, snap) in [("player", &player), ("target", &target)] {
match snap {
Some(cur) => {
let prev = feed.last.get(token);
let prev = memo.last.get(token);
if prev != Some(cur) {
fire_transitions(&mut script, token, prev, cur);
feed.last.insert(token.to_string(), cur.clone());
memo.last.insert(token.to_string(), cur.clone());
}
}
None => {
// Clearing a token isn't a UNIT_* event; the target frame reacts to
// PLAYER_TARGET_CHANGED below.
feed.last.remove(token);
memo.last.remove(token);
}
}
}
// PLAYER_TARGET_CHANGED (no args, real WoW's shape) when the selection changes.
if selection.guid != feed.target_guid {
feed.target_guid = selection.guid;
if selection.guid != memo.target_guid {
memo.target_guid = selection.guid;
script.fire_event("PLAYER_TARGET_CHANGED", vec![]);
}
@@ -972,9 +993,9 @@ fn feed_units(
// the COMBAT_TEXT_UPDATE emission pin).
if let Some((store, _)) = self_pair {
let in_combat = store.0.unit_flags() & 0x0008_0000 != 0;
if feed.in_combat != Some(in_combat) {
let first_sight = feed.in_combat.is_none();
feed.in_combat = Some(in_combat);
if memo.in_combat != Some(in_combat) {
let first_sight = memo.in_combat.is_none();
memo.in_combat = Some(in_combat);
if !first_sight || in_combat {
script.fire_event(
if in_combat {
@@ -997,14 +1018,14 @@ fn feed_units(
// guid == localPlayer gate) and only on a change, never on the descriptor that first carries it.
if let Some((store, _)) = self_pair {
let desired = store.0.player_flags() & PLAYER_FLAGS_PVP_DESIRED != 0;
if let Some((toast, verbose)) = pvp_announcement(feed.pvp_desired, desired) {
if let Some((toast, verbose)) = pvp_announcement(memo.pvp_desired, desired) {
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(toast.to_string())]);
chat.push_event(ChatEvent::text_only(
ChatEventKind::System,
verbose.to_string(),
));
}
feed.pvp_desired = Some(desired);
memo.pvp_desired = Some(desired);
}
// The combo-point feed: `PLAYER_FIELD_BYTES` byte 1 and the `PLAYER_FIELD_COMBO_TARGET` GUID
@@ -1026,8 +1047,8 @@ fn feed_units(
store.0.player_combo_points().unwrap_or(0),
store.0.player_combo_target(),
);
if let Some(fire) = combo_edge(feed.last_combo, banked) {
feed.last_combo = Some(banked);
if let Some(fire) = combo_edge(memo.last_combo, banked) {
memo.last_combo = Some(banked);
script.set_combo_points(banked.0, banked.1);
if fire {
script.fire_event("PLAYER_COMBO_POINTS", vec![]);
+93 -10
View File
@@ -3,19 +3,25 @@
//! In the reference every player setting is a CVar: a case-insensitively named string value with
//! a registered default, read and written from Lua through `GetCVar`/`SetCVar`/`GetCVarDefault`
//! (the 1.12 options panels are thin UI over this table). benilla keeps the same shape at the
//! same seam: the **host registers** the vars it actually backs with engine knobs
//! ([`super::UiScript::register_cvars`] — the honest-tree rule: no key without a knob), Lua
//! reads/writes them synchronously, and each Lua write lands on the **change queue** the host
//! same seam: the **host registers** the vars it implements
//! ([`super::UiScript::register_cvars`] — most backed by an engine knob, a few consumed by Lua
//! alone), Lua reads/writes them synchronously, and each Lua write lands on the **change queue** the host
//! drains per frame ([`super::UiScript::take_cvar_changes`]) to sync its resources and mark the
//! config file dirty. Host-side writes ([`super::UiScript::set_cvar_host`] — the loaded config,
//! an env override) do NOT ride the queue: the host already knows, and an echo would re-dirty
//! the file it just loaded.
//!
//! Values are **strings**, like the client's (`GetCVar` returns a string; consumers parse and
//! clamp at their own edge). An unknown name warns once and no-ops — every benilla CVar is
//! host-registered, so an unknown key is a typo or an unshipped feature, never a storage slot.
//! Divergence, disclosed: the client also surfaces `RegisterCVar` to Lua; nothing in our shipped
//! XML calls it, so it is not modeled until something does.
//! clamp at their own edge). An unknown name warns once and no-ops — a benilla CVar is either
//! host-registered or addon-declared through `RegisterCVar` (decision 1195), so an unknown key
//! is a typo or an unshipped feature, never a storage slot.
//!
//! **The table dies with the VM, so persistence bridges it** (decision 1291): in the reference
//! this store is engine memory and survives every `ReloadUI`; ours is per-VM state, replaced at
//! every login and reload (1290/1291). The host hands each fresh VM the config file's values
//! ([`super::UiScript::set_cvar_saved_base`]) before anything registers, and registration —
//! either kind — starts a key at its saved value. The host folds the dying VM's table back into
//! its persist state on the session edge, so the two halves meet.
use mlua::{Lua, Value};
@@ -33,8 +39,26 @@ pub(crate) struct CvarSlot {
}
impl super::UiScript {
/// Hand registration the config file's persisted values (decision 1291): name → value, keys
/// lowercased here. Set **before** any `register_cvars` / addon `RegisterCVar` runs in this
/// VM; a name registered while present here starts at the saved value instead of its default.
///
/// This is the bridge that makes the per-VM table behave like the reference's engine-side
/// one (where the store outlives every `ReloadUI`): a CVar with no host knob
/// (`statusBarText`) and a CVar only an addon declares would otherwise revert to default on
/// every VM replacement — and the saver, seeing value == default, would then *strip the
/// player's setting from the file*.
pub fn set_cvar_saved_base(&mut self, entries: impl IntoIterator<Item = (String, String)>) {
self.model_mut().cvars_saved_base = entries
.into_iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect();
}
/// Register the host-backed CVars (name, default) — boot-time, idempotent. A re-register of
/// a live table refreshes defaults but never clobbers a value someone already set.
/// a live table refreshes defaults but never clobbers a value someone already set. A fresh
/// registration starts at the saved-base value when the config file carries one
/// ([`Self::set_cvar_saved_base`]), else at the default.
pub fn register_cvars<'a>(&mut self, vars: impl IntoIterator<Item = (&'a str, &'a str)>) {
let mut model = self.model_mut();
for (name, default) in vars {
@@ -42,11 +66,16 @@ impl super::UiScript {
match model.cvars.get_mut(&key) {
Some(slot) => slot.default = default.to_string(),
None => {
let value = model
.cvars_saved_base
.get(&key)
.cloned()
.unwrap_or_else(|| default.to_string());
model.cvars.insert(
key,
CvarSlot {
name: name.to_string(),
value: default.to_string(),
value,
default: default.to_string(),
},
);
@@ -153,9 +182,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let value = value.as_ref().and_then(value_to_string).unwrap_or_default();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let key = name.to_ascii_lowercase();
// The saved base outranks the declared default (decision 1291): an addon-declared
// CVar the player has set persists in the config file, and this lookup is how the
// saved value survives the VM being replaced — the declared value stays the DEFAULT,
// so the saver still knows what "moved off default" means for this key.
let saved = model.cvars_saved_base.get(&key).cloned();
model.cvars.entry(key).or_insert(CvarSlot {
name,
value: value.clone(),
value: saved.unwrap_or_else(|| value.clone()),
default: value,
});
Ok(())
@@ -353,6 +387,55 @@ mod tests {
///
/// It is a real 1.12 CVar (`0x83f2d0`, persisted — the client builds its SavedVariables path
/// from it) and it had no value here at all. `Ace/AceState.lua:27` is
/// **Registration honors the saved base** (decision 1291) — the bridge that makes the
/// per-VM table behave like the reference's engine-side store: a knobless CVar keeps the
/// player's persisted value across a VM replacement, and the default stays the DEFAULT so
/// "moved off default" still means something to the saver.
#[test]
fn registration_starts_at_the_saved_value_not_the_default() {
let mut s = UiScript::new().unwrap();
s.set_cvar_saved_base([("StatusBarText".to_string(), "1".to_string())]);
s.register_cvars([("statusBarText", "0"), ("farclip", "500")]);
assert_eq!(
s.eval::<String>(r#"return GetCVar("statusBarText")"#)
.unwrap(),
"1",
"a saved value outranks the registered default (any key case)"
);
assert_eq!(
s.eval::<String>(r#"return GetCVarDefault("statusBarText")"#)
.unwrap(),
"0",
"…while the default stays the default"
);
assert_eq!(
s.eval::<String>(r#"return GetCVar("farclip")"#).unwrap(),
"500",
"a key the file never carried starts at its default"
);
}
/// The same law through the Lua half: an addon's `RegisterCVar` (decision 1195) starts at
/// the persisted value, so its setting survives the VM being replaced (1290/1291).
#[test]
fn an_addon_registered_cvar_starts_at_its_saved_value() {
let mut s = UiScript::new().unwrap();
s.set_cvar_saved_base([("myaddon_scale".to_string(), "2.5".to_string())]);
s.run(r#"RegisterCVar("MyAddon_Scale", "1.0")"#).unwrap();
assert_eq!(
s.eval::<String>(r#"return GetCVar("MyAddon_Scale")"#)
.unwrap(),
"2.5"
);
assert_eq!(
s.eval::<String>(r#"return GetCVarDefault("MyAddon_Scale")"#)
.unwrap(),
"1.0",
"the declared value is the DEFAULT, not the live value"
);
}
/// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which EVERY Ace addon runs at
/// PLAYER_ENTERING_WORLD, so nil became `gsub(nil, ...)` and took the whole family down.
///
+8
View File
@@ -302,6 +302,13 @@ pub(crate) struct Model {
/// The CVar table (decision 0954, [`super::cvars`]): lowercase name → slot. Host-registered
/// only; Lua reads/writes through `GetCVar`/`SetCVar`/`GetCVarDefault`.
pub(crate) cvars: HashMap<String, super::cvars::CvarSlot>,
/// The persisted values registration honors (decision 1291): lowercase name → the config
/// file's value, set by the host **before** any registration. A CVar registered while its
/// name is in here — host-registered or an addon's `RegisterCVar` — starts at the saved
/// value, not the default. This is what makes a knobless CVar (`statusBarText`) and an
/// addon-declared one survive the VM being replaced: in the reference the table is engine
/// memory and outlives every `ReloadUI`; ours is per-VM, so the file value is the bridge.
pub(crate) cvars_saved_base: HashMap<String, String>,
/// `(registered name, new value)` per Lua `SetCVar` since the app's last
/// [`super::UiScript::take_cvar_changes`] drain — the knob-sync + config-dirty cue.
pub(crate) cvar_changes: Vec<(String, String)>,
@@ -1075,6 +1082,7 @@ impl Model {
pvp_toggles: 0,
sound_queue: Vec::new(),
cvars: HashMap::new(),
cvars_saved_base: HashMap::new(),
cvar_changes: Vec::new(),
cvars_warned: HashSet::new(),
saved_names: Vec::new(),
+13 -3
View File
@@ -1,5 +1,5 @@
//! The **session-exit API** (decision 0674) — the four globals the game menu's Logout and Exit
//! Game buttons call, and the two dialogs' answers to them.
//! The **session API** (decisions 0674, 1291) — the four exit globals the game menu's Logout and
//! Exit Game buttons call, the two dialogs' answers to them, and `ReloadUI()`.
//!
//! The shape is [`super::duel`]'s: nothing to read, only the outbound half. Each call queues a
//! [`SessionRequest`] the app drains ([`super::UiScript::take_session_requests`]) and turns into a
@@ -38,6 +38,15 @@ pub enum SessionRequest {
CancelLogout,
/// `ForceQuit()` — end the process now, no server round trip.
ForceQuit,
/// `ReloadUI()` — tear this VM down and build a fresh one, without leaving the world.
///
/// The reference's `ReloadUI 0x4884d0` reads no arguments and returns no values; it only sets
/// `ds:0xb4b3f4`, and the per-frame callback `0x495590` runs the teardown/rebuild pair
/// (`0x490bd0` → `0x48fbf0`) on the NEXT frame — a deferral that doubles as the reentrancy
/// guard: the Lua state being destroyed is never the one mid-way through executing the call.
/// Queuing an intent the app runs outside any VM call is that same guard in this engine's
/// shape (wow-re `system/ui/scratch/savedvariables-protocol.md` §2, byte-verified).
ReloadUi,
}
impl super::UiScript {
@@ -54,7 +63,7 @@ impl super::UiScript {
}
}
/// Register the four session-exit globals.
/// Register the five session globals — the four exit verbs, and `ReloadUI`.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -63,6 +72,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
("Quit", SessionRequest::Quit),
("CancelLogout", SessionRequest::CancelLogout),
("ForceQuit", SessionRequest::ForceQuit),
("ReloadUI", SessionRequest::ReloadUi),
] {
g.set(
name,
@@ -407,6 +407,9 @@ fn gate_doodad_anim(
radius,
center,
instance: None,
// …and no rooms either, for the same reason: a placement's particles-only
// model is not a building's prop, so nothing gates it on a portal PVS.
room: None,
};
fade.in_draw_set(
cam_pos,
@@ -420,6 +423,7 @@ fn gate_doodad_anim(
false,
),
fade.exterior_admitted(&exterior_gate, camera_instance),
fade.room_admitted(None), // no rooms ⇒ admitted; the single spelling, not `true`
)
})
} else {
@@ -153,6 +153,28 @@ pub(super) const MAX_POINT_LIGHTS: usize = 256;
/// (0285: each unit picks its ≤3 nearest from this table) bounded.
const POINT_PACK_RADIUS: f32 = 300.0;
/// **The rooms a point light belongs to** — a WMO's own MOLT fixture (the groups whose MOLR names
/// it) or one of its props' M2 lights (the groups whose MODR names the prop). Absent on an ADT map
/// doodad's light, a creature's, a GameObject's: nothing claims those.
///
/// The fourth rider of decision 0689's law, after the prop's mesh, its particle clouds and its
/// ribbon trails. The reference never needs it: a WMO's furniture is instantiated out of each
/// **visible** group's MODR list, so a torch in a culled room does not exist and registers no
/// light. Its light-register walk really does have no visibility term of its own — byte-verified,
/// wow-re `m2-light-emitter-instances.md` §4: the gate for a model entering the register walk is
/// the scene update-list activation flag `[model+0x10]`, "not visibility, not distance, not LOD",
/// and the ≤4 cap is purely receiver-side. So the faithful fix is NOT a visibility test bolted onto
/// the gather; it is that the SOURCE should not be there at all, which is what this component says.
/// [`build_light_data`] drops the light while its rooms are culled, exactly as the model-visibility
/// authority drops the prop's own submeshes.
///
/// A newtype rather than a bare [`crate::wmo_portal::WmoGroupVis`] on purpose: that component on a
/// light entity would enlist it in `apply_model_visibility`'s `group_only` query — a `PointLight`
/// carries `Visibility` and `GlobalTransform`, so it matches — making the model-visibility
/// authority a second writer on an entity whose `Visibility` nothing reads (decision 0025).
#[derive(Component)]
pub struct LightRooms(pub(crate) crate::wmo_portal::WmoGroupVis);
/// Main-world resource holding the packed light for this frame; extracted into the render world where
/// [`upload_light`] writes it. Rebuilt every frame by [`build_light_data`] (cheap — one std430 pack).
#[derive(Resource, Clone, Copy, ExtractResource)]
@@ -249,7 +271,9 @@ fn build_light_data(
debug: Res<DebugState>,
view: Res<ViewDistance>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
lights_q: Query<(&PointLight, &GlobalTransform)>,
lights_q: Query<(&PointLight, &GlobalTransform, Option<&LightRooms>)>,
// The per-frame portal PVS, for the room term below ([`LightRooms`]).
portals: Query<&crate::wmo_portal::WmoPortalInstance>,
mut data: ResMut<WowLightData>,
time: Res<Time>,
mut last_dump: Local<f64>,
@@ -299,7 +323,17 @@ fn build_light_data(
let cam_pos = cam.single().map(|t| t.translation()).unwrap_or(Vec3::ZERO);
let mut pts: Vec<(f32, Vec3, f32, [f32; 3])> = lights_q
.iter()
.filter_map(|(pl, gt)| {
.filter(|(_, _, rooms)| {
// The ROOM term (decision 0689's law, fourth lane — see [`LightRooms`]). Not a
// visibility test bolted onto a faithful gather: the reference's register walk has no
// such term either, it simply never has a culled room's torch to register. Ungated for
// every light that names no rooms, which is every light outside a building.
crate::wmo_portal::room_admits(
rooms.map(|r| &r.0),
rooms.and_then(|r| portals.get(r.0.instance).ok()),
)
})
.filter_map(|(pl, gt, _)| {
let p = gt.translation();
let d2 = p.distance_squared(cam_pos);
(d2 < POINT_PACK_RADIUS * POINT_PACK_RADIUS).then(|| {
+1 -1
View File
@@ -17,7 +17,7 @@ mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ Mesh
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
mod sh; // the model SH light-probe coefficient math
pub use blob::LightBlob;
pub use global_light::{new_shared_light_buffer, SharedLightBuffer};
pub use global_light::{new_shared_light_buffer, LightRooms, SharedLightBuffer};
pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES};
pub use resolve::fog_range;
// The std430 layout itself — row indices, byte sizes, region offsets and the folds that fill them
+109 -9
View File
@@ -35,7 +35,7 @@ mod emitdump;
mod model;
mod quads;
pub mod render;
mod sim;
pub(crate) mod sim; // `SceneGates` is the ribbon sim's draw-set input too (decision 1291)
use emit::{emit_local, next_u32, rand01, rand_s11};
use sim::simulate_particles;
@@ -459,7 +459,10 @@ impl ParticleEmitter {
/// particle-side rule (emission LOD, no cull — decision 0151) applies only WITHIN the draw set.
/// Attached by the terrain spawn path; entity-owned emitters (creatures/GameObjects/spells) are
/// bounded by server visibility instead and carry no fade.
#[derive(Component)]
/// The rule is shared by BOTH emitter families — the quad clouds here and the ribbon trails in
/// [`crate::ribbons`], which take this by value. `Clone` so one call site can build a placed
/// model's gate ONCE and hand the same value to both, rather than two constructions that can drift.
#[derive(Component, Clone)]
pub struct EmitterFade {
/// World bounding-sphere radius of the OWNER doodad (selects the fade band).
pub radius: f32,
@@ -472,6 +475,27 @@ pub struct EmitterFade {
/// which the owner submesh gets through `WmoGroupVis` — an emitter carries no such component,
/// so it carries the instance directly).
pub(crate) instance: Option<Entity>,
/// The **rooms** of [`Self::instance`] that name this owner — the prop's own
/// [`crate::wmo_portal::WmoGroupVis`], by value.
///
/// It answers the question [`Self::instance`] cannot: not *which building am I furniture of*
/// (identity, for the exemption above) but *is the room I stand in drawn this frame*. The
/// reference never has to ask, because it 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 has no emitters to tick. We create props
/// once and cull them per frame, so every rider of a prop has to ask for itself, and this is
/// the one place the answer is spelled.
///
/// `None` = not a building's prop (an ADT map doodad, a creature, a GameObject, a spell kit),
/// or a prop no group names at all — nothing claims it, so nothing gates it here. When it is
/// `Some`, its `instance` is the same entity as [`Self::instance`]: both are built from one
/// expression at one call site (`terrain_stream::spawn`), which is what keeps them from
/// drifting.
///
/// By value and not as a component on the emitter entity, because a `WmoGroupVis` there would
/// enlist it in `apply_model_visibility`'s `group_only` query — a second `Visibility` writer on
/// an entity whose `Visibility` this lane does not read (decision 0025).
pub(crate) room: Option<crate::wmo_portal::WmoGroupVis>,
}
impl EmitterFade {
@@ -488,11 +512,12 @@ impl EmitterFade {
/// The **draw-set admission rule** in one place: is the owner doodad in the frame's scene
/// worklist, and therefore does its emitter tick and draw this frame?
///
/// Four terms, all against the owner's fade sphere (the reference's `[rec+0x68]`):
/// Five terms, the first four against the owner's fade sphere (the reference's `[rec+0x68]`):
/// 1. the radius-tiered distance fade hasn't reached zero (`FUN_00683f80`),
/// 2. the sphere is inside the far-clip wall ([`crate::view::within_farclip`]),
/// 3. `lateral_in_frustum` — the caller's frustum sphere test on the side/near planes,
/// 4. `exterior_admitted` — the caller's exterior-window test (decision 0786).
/// 4. `exterior_admitted` — the caller's exterior-window test (decision 0786),
/// 5. `room_admitted` — the caller's portal-PVS test ([`Self::room_admitted`], decision 0689).
///
/// Term 2 is the one bug B39 was missing (decision 0678), and it cannot be folded into term 1:
/// [`crate::model_fade::doodad_fade_alpha`] returns a flat `1.0` for any owner bigger than
@@ -505,6 +530,12 @@ impl EmitterFade {
/// left its emitter running, which is a mushroom's spore cloud hanging in a sealed dungeon (the
/// director's report: `plaguelandmushroom01`, 3 emitters, 59.7 yd through a wall).
///
/// Term 5 is the same omission one layer *in*: the exterior window asks whether the BUILDING is
/// in the scene, and answers "yes" for every building the camera can see — which says nothing
/// about the sealed room inside it that this emitter's owner actually stands in. Ninety Caverns
/// of Time ribbon trails burned up through 200 yd of Tanaris rock on exactly that gap
/// (decision 1289); this is the shared spelling that decision named as the fix.
///
/// Pure and total so the rule is pinned by tests without an ECS (the `model_fade` pattern).
pub fn in_draw_set(
&self,
@@ -513,6 +544,7 @@ impl EmitterFade {
farclip: f32,
lateral_in_frustum: bool,
exterior_admitted: bool,
room_admitted: bool,
) -> bool {
let (dx, dz) = (self.center.x - cam_pos.x, self.center.z - cam_pos.z);
let horiz = (dx * dx + dz * dz).sqrt();
@@ -520,6 +552,16 @@ impl EmitterFade {
&& crate::view::within_farclip(farclip, cam_pos, cam_fwd, self.center, self.radius)
&& lateral_in_frustum
&& exterior_admitted
&& room_admitted
}
/// **Term 5 for this emitter, given the live placement its rooms belong to** — kept beside the
/// rule for the same reason [`Self::exterior_admitted`] is, so the particle sim, the ribbon sim
/// and the meshless-host anim gate ask one question instead of three. The arms, and why the
/// last one fails CLOSED, are [`crate::wmo_portal::room_admits`] — shared with the light lane,
/// which rides the same rooms for the same reason.
pub fn room_admitted(&self, instance: Option<&crate::wmo_portal::WmoPortalInstance>) -> bool {
crate::wmo_portal::room_admits(self.room.as_ref(), instance)
}
/// Term 4 for this emitter, given the frame's gate and the placement the camera is inside. Kept
@@ -899,8 +941,9 @@ pub(crate) mod tests {
radius,
center: Vec3::new(0.0, 0.0, -depth),
instance: None,
room: None,
}
.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, farclip, true, true)
.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, farclip, true, true, true)
}
/// **The owner-last rung, pinned** (decisions 0719/0721), through the wrapper the renderer
@@ -959,12 +1002,16 @@ pub(crate) mod tests {
radius: 2.0,
center: Vec3::new(0.0, 0.0, -60.0),
instance: None,
room: None,
};
assert!(f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, true, true));
assert!(!f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, false, true));
assert!(f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, true, true, true));
assert!(!f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, false, true, true));
// …and the exterior-window term is ANDed the same way (0786): a doodad no portal window
// admits is not in the worklist, so its emitter neither ticks nor draws.
assert!(!f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, true, false));
assert!(!f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, true, false, true));
// …as is the room term (0689/1289): the window admits the BUILDING, the PVS admits the
// ROOM, and a prop in a culled room is one the reference never instantiated at all.
assert!(!f.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, 777.0, true, true, false));
}
/// **The exemption, on the emitter lane.** A sealed room (no windows) admits no exterior scene
@@ -984,6 +1031,7 @@ pub(crate) mod tests {
radius: 2.0,
center: Vec3::new(0.0, 0.0, -60.0),
instance,
room: None,
};
assert!(
@@ -1006,6 +1054,57 @@ pub(crate) mod tests {
assert!(!fade(None).exterior_admitted(&sealed, None));
}
/// **A prop in a culled room emits nothing, and an orphaned emitter refuses.**
///
/// 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 has no emitters to tick. Our props' *meshes* are culled by exactly
/// this predicate and their emitters were not, which is how Caverns of Time's twelve
/// energy-trail props kept ninety additive ribbon strips burning up through 200 yd of rock into
/// Tanaris while every one of their submeshes was correctly hidden (decision 1289).
///
/// All four arms, including the one that fails CLOSED where the rest of the cull fails open.
#[test]
fn a_prop_in_a_culled_room_emits_nothing_and_an_orphan_refuses() {
use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance};
let cot = WmoPortalInstance {
handle: Handle::default(),
world_from_local: bevy::math::Affine3A::IDENTITY,
name_set: 0,
liquid_visited: vec![false; 3],
flooded: vec![None; 3],
visible: vec![true, false, false],
};
let fade = |groups: Option<&[u16]>| EmitterFade {
radius: 2.0,
center: Vec3::new(0.0, 0.0, -60.0),
instance: None,
room: groups.map(|g| WmoGroupVis {
instance: Entity::PLACEHOLDER,
groups: g.into(),
}),
};
// Not a building's prop at all — an ADT map doodad, a creature, a held item's streak.
assert!(
fade(None).room_admitted(Some(&cot)),
"an unclaimed emitter is never gated"
);
// The room the flood reached draws; the ones it did not, do not.
assert!(fade(Some(&[0])).room_admitted(Some(&cot)));
assert!(!fade(Some(&[1, 2])).room_admitted(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 whole point of sharing the predicate.
assert!(fade(Some(&[1, 0])).room_admitted(Some(&cot)));
// …and the asymmetry that must NOT be the cull's usual fail-open: an emitter whose
// placement has despawned refuses, rather than drawing one last frame through the hole
// where a building used to be.
assert!(
!fade(Some(&[0])).room_admitted(None),
"an orphaned emitter draws nothing"
);
}
/// The emitter gate and the owner mesh's own cull must agree on the boundary — they read the
/// same `within_farclip` now, and this pins that they cannot drift apart again. An emitter
/// admitted where its owner's mesh is culled IS bug B39.
@@ -1032,8 +1131,9 @@ pub(crate) mod tests {
radius,
center,
instance: None,
room: None,
}
.in_draw_set(cam, fwd, farclip, true, true);
.in_draw_set(cam, fwd, farclip, true, true, true);
assert_eq!(
mesh_drawn, emitter_drawn,
"wall disagreement at farclip {farclip} depth {depth} radius {radius}"
+42 -13
View File
@@ -198,14 +198,42 @@ fn drive_child(
}
/// The **draw-set gate's** scene inputs, bundled: the far-clip wall the gate bounds emitters at
/// (0678) and the exterior-window test a WMO interior applies to everything outside it (0786),
/// with the camera's own room as its one exemption. One `SystemParam` because they are read
/// together, at one place, and Bevy caps a system at 16 parameters.
/// (0678), the exterior-window test a WMO interior applies to everything outside it (0786) with the
/// camera's own room as its one exemption, and the per-frame portal PVS the rooms *inside* a
/// building are gated by (0689/1289). One `SystemParam` because they are read together, at one
/// place, and Bevy caps a system at 16 parameters — which `simulate_particles` already sits at, so
/// the portal query joins this bundle rather than becoming a seventeenth.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct SceneGates<'w> {
pub(crate) struct SceneGates<'w, 's> {
view: Res<'w, crate::view::ViewDistance>,
exterior_windows: Res<'w, crate::wmo_portal::ExteriorWindows>,
camera_claim: Res<'w, crate::wmo_portal::CameraInteriorClaim>,
portals: Query<'w, 's, &'static crate::wmo_portal::WmoPortalInstance>,
}
impl SceneGates<'_, '_> {
/// The three per-frame values every draw-set caller needs before it can ask
/// [`EmitterFade::in_draw_set`]: the far-clip wall, the built exterior gate, and the placement
/// the camera is standing in. Built ONCE per walk (the gate is a handful of 6-plane frusta).
pub(crate) fn scene(
&self,
cam: Option<(&GlobalTransform, &Projection)>,
) -> (f32, crate::exterior_cull::ExteriorGate, Option<Entity>) {
(
self.view.farclip,
crate::exterior_cull::ExteriorGate::build(&self.exterior_windows, cam),
self.camera_claim.0.map(|c| c.room.instance),
)
}
/// This emitter's term 5 — the live placement its rooms belong to, resolved and asked.
pub(crate) fn room_admits(&self, fade: &EmitterFade) -> bool {
fade.room_admitted(
fade.room
.as_ref()
.and_then(|r| self.portals.get(r.instance).ok()),
)
}
}
/// The **water-plane interleave** inputs ([`crate::sky_order::FAR_SIDE_BIAS`], where the
@@ -430,13 +458,10 @@ pub(super) fn simulate_particles(
// The far-clip wall's axis — the SAME forward `debug_panel::visibility` measures the owner
// doodad's own cull along, so an emitter and its owner cross the wall together.
let cam_fwd = Vec3::from(cam_tf.forward());
// The exterior gate, built once for the whole emitter walk — the same value the model
// visibility authority and `exterior_cull` ask (0784/0786: one spelling of the window test).
let exterior_gate = crate::exterior_cull::ExteriorGate::build(
&gates.exterior_windows,
Some((cam_tf, projection)),
);
let camera_instance = gates.camera_claim.0.map(|c| c.room.instance);
// The far-clip wall, the exterior gate and the camera's own room — built once for the whole
// emitter walk, the same values the model visibility authority and `exterior_cull` ask
// (0784/0786: one spelling of the window test).
let (farclip, exterior_gate, camera_instance) = gates.scene(Some((cam_tf, projection)));
// `$WOW_PARTICLE_DEPTHDUMP` (B16): is this a dump frame? Decided once per run.
let dump_frame = dumps.depth_frame(time.elapsed_secs());
// `$WOW_EMIT_DUMP`: is this a dump tick? Decided once per frame, for the whole walk.
@@ -484,7 +509,7 @@ pub(super) fn simulate_particles(
let in_set = f.in_draw_set(
cam_pos,
cam_fwd,
gates.view.farclip,
farclip,
// Lateral planes only (`intersect_far = false`) — the depth bound is the farclip
// term inside `in_draw_set`, deliberately; see `view::within_farclip`.
frustum.intersects_sphere(
@@ -498,6 +523,10 @@ pub(super) fn simulate_particles(
// outside is not in the worklist at all, so its emitter neither ticks nor draws —
// which the mesh gate already knew and this one did not.
f.exterior_admitted(&exterior_gate, camera_instance),
// …and the room term (0689/1289): the window above admits a whole BUILDING, which
// says nothing about the sealed room the owner prop actually stands in. The mesh
// gate has always known this one too, through the prop's own `WmoGroupVis`.
gates.room_admits(f),
);
if !in_set {
// Frozen: the pool writes no quads this frame (the shared stream is cleared
@@ -545,7 +574,7 @@ pub(super) fn simulate_particles(
None => Some(emitter.anchor_pos),
};
if let Some(at) = subject {
if !crate::view::within_farclip(gates.view.farclip, cam_pos, cam_fwd, at, 0.0) {
if !crate::view::within_farclip(farclip, cam_pos, cam_fwd, at, 0.0) {
if !emitter.gated {
emitter.gated = true;
for slot in &emitter.model_instances {
+115 -115
View File
@@ -24,6 +24,8 @@ use std::collections::VecDeque;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::ModelRibbon;
use benilla_formats::ParticleBlend;
use bevy::camera::primitives::{Frustum, Sphere as CullSphere};
use bevy::camera::Projection;
use bevy::prelude::*;
use crate::particles::buffer::{EffectDrawSpec, EffectFog, EffectQuads, EffectVertex};
@@ -48,29 +50,6 @@ pub struct RibbonVerdict {
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 {
@@ -127,23 +106,29 @@ 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).
/// **Is this trail's model in the frame's scene at all?** — the placed model's own
/// [`crate::particles::EmitterFade`], carried by value.
///
/// 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).
/// A trail is one of its model's emitters, and the reference ticks and draws a model's
/// emitters — quad clouds and ribbons alike — inside **that model's** draw step. So a trail
/// takes exactly the gate the quad clouds beside it take, from the same construction at the
/// same spawn site: distance fade, the far-clip wall, the lateral frustum, the exterior window,
/// and the room ([`crate::particles::EmitterFade::in_draw_set`]). The ribbon lane used to have
/// **no term of any kind** here, which is how ninety Caverns of Time trails burned up through
/// 200 yd of Tanaris rock (decision 1289, the room term) and how a placed prop's streamer drew
/// past the far-clip wall its own mesh had already been culled by (bug B39, decision 0678 —
/// fixed for the quad clouds and left open here).
///
/// `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>,
/// By value and not as a component, because a trail entity holding an `EmitterFade` would be
/// picked up by the particle sim's own optional-fade query, and its `WmoGroupVis` inside would
/// enlist it in `apply_model_visibility`'s group query — a second `Visibility` writer on an
/// entity whose `Visibility` nobody reads (decision 0025).
///
/// `None` = an ENTITY-owned trail (a creature, a GameObject, a missile, a held item), bounded
/// by server visibility and by [`Self::alpha_src`] instead — the same split
/// [`crate::particles::EmitterFade`] itself takes, and with the same far-clip backstop applied
/// in [`simulate_ribbons`] for the one lane server visibility does not bound (transports).
fade: Option<crate::particles::EmitterFade>,
/// 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>,
@@ -211,7 +196,7 @@ pub fn spawn_ribbon(
owner_scale: f32,
seq: RibbonSeq,
alpha_src: Option<Entity>,
room: Option<crate::wmo_portal::WmoGroupVis>,
fade: Option<crate::particles::EmitterFade>,
) -> 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() {
@@ -248,7 +233,7 @@ pub fn spawn_ribbon(
owner: Some(owner),
seq,
alpha_src,
room,
fade,
edges: VecDeque::new(),
accumulator: 0.0,
age: 0.0,
@@ -281,25 +266,38 @@ pub(crate) fn simulate_ribbons(
// attached-model chain (0833).
model_alphas: crate::model_fade::ModelAlphas,
// Trails belong to the world lane (no booth ribbons; a booth-parked owner's strip is eaten
// by the shader's farclip wall, exactly as on the material path).
world_cam: Query<Entity, With<WorldCamera>>,
// by the shader's farclip wall, exactly as on the material path). The frustum/projection come
// with it because the draw-set gate below needs the same lateral test the quad clouds take.
world_cam: Query<(Entity, &GlobalTransform, &Frustum, &Projection), With<WorldCamera>>,
// 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>,
// The draw-set gate's scene inputs — far-clip wall, exterior window, portal PVS — the SAME
// bundle `simulate_particles` reads, so the two emitter families cannot answer differently
// ([`RibbonTrail::fade`]).
gates: crate::particles::sim::SceneGates,
mut verdict: ResMut<RibbonVerdict>,
mut trails: Query<(
Entity,
&mut RibbonTrail,
&mut Transform,
&mut GlobalTransform,
)>,
mut trails: Query<
(
Entity,
&mut RibbonTrail,
&mut Transform,
&mut GlobalTransform,
),
// The camera's `&GlobalTransform` read above must be provably disjoint from these writes
// (a trail never rides the camera entity) — the particle sim's emitter query says the same.
Without<WorldCamera>,
>,
) {
*verdict = RibbonVerdict::default();
let Ok(cam) = world_cam.single() else {
let Ok((cam, cam_tf, frustum, projection)) = world_cam.single() else {
return;
};
let cam_pos = cam_tf.translation();
// The far-clip wall's axis — the same forward the owner mesh's own cull measures along, so a
// trail and the model it belongs to cross the wall together.
let cam_fwd = Vec3::from(cam_tf.forward());
let (farclip, exterior_gate, camera_instance) = gates.scene(Some((cam_tf, projection)));
let dt = time.delta_secs().min(0.1);
let now = time.elapsed_secs();
for (entity, mut trail, mut entity_tf, mut entity_global) in &mut trails {
@@ -309,7 +307,7 @@ pub(crate) fn simulate_ribbons(
owner,
seq,
alpha_src,
room,
fade,
edges,
accumulator,
age,
@@ -317,13 +315,6 @@ pub(crate) fn simulate_ribbons(
bias,
water_bound,
} = &mut *trail;
// The keyed look tracks sample on the trail's clip clock (see [`RibbonTrail::age`]):
// heights at edge-commit time (each edge keeps the width it was born with — the
// reference stores the vertex pair per edge), colour/alpha per frame for the whole strip.
*age += dt;
let ms = *age * 1000.0;
let h_above = def.height_above.sample_ms(ms).max(0.0);
let h_below = def.height_below.sample_ms(ms).max(0.0);
// Owner gone (despawned missile/creature, unequipped item root) → DRAIN: no new
// commits, the committed edges age out, and the trail despawns with its last edge
@@ -349,6 +340,70 @@ pub(crate) fn simulate_ribbons(
}
verdict.trails += 1;
// The **draw-set** gate: is this trail's MODEL in the frame's scene at all? A trail is one
// of its model's emitters, and the reference ticks a model's emitters inside that model's
// draw step — so a culled model's trail neither commits nor ages. FROZEN, the same shape
// `simulate_particles` freezes a culled owner's pool in, resuming from frozen state with
// one frame's dt rather than catching up. Until now the ribbon lane had no term here at
// all: not distance, not the far-clip wall, not the frustum, not the exterior window, not
// the portal PVS (decisions 0678 / 0786 / 1289 — each of which closed this hole for the
// quad clouds and left it open for the strips).
//
// Only while the owner LIVES. Freezing a DRAINING trail strands it — it can never empty
// its edges, so it never reaches the self-despawn above and leaks for the session; the
// particle sim states the identical exclusion.
let admitted = match (head, fade.as_ref()) {
// A placed model — an ADT map doodad or a WMO prop. The full five-term rule, in its
// one spelling, against the owner model's fade sphere.
(Some(_), Some(f)) => f.in_draw_set(
cam_pos,
cam_fwd,
farclip,
// Lateral planes only (`intersect_far = false`): the depth bound is the far-clip
// term inside `in_draw_set`, deliberately — see `view::within_farclip`.
frustum.intersects_sphere(
&CullSphere {
center: f.center.into(),
radius: f.radius,
},
false,
),
f.exterior_admitted(&exterior_gate, camera_instance),
gates.room_admits(f),
),
// ENTITY-owned — a creature, a GameObject, a missile, a held item. No fade sphere:
// the population is bounded by server visibility, and by `alpha_src` below. Except
// transports, which vmangos streams MAP-WIDE, so the bare wall applies to every
// world-lane trail exactly as decision 0678 applies it to every world-lane emitter.
// Measured live from the owner, never from a stored anchor: a frozen trail stops
// refreshing its anchor, so gating on that would latch a moving owner out of
// existence the first time it crossed the wall.
(Some((node, _)), None) => {
crate::view::within_farclip(farclip, cam_pos, cam_fwd, node, 0.0)
}
// Draining — never frozen (see above).
(None, _) => true,
};
if !admitted {
// Hold the clock with the trail. Edge expiry is measured against the SHARED clock
// (`now - born`), so a freeze that let `now` run on would age every stored edge out
// while the model was away and hand back an empty strip on re-entry — the opposite of
// "resumes from frozen state". Advancing `born` by the frame's dt keeps each edge's
// relative age exactly where the freeze found it.
for e in edges.iter_mut() {
e.born += dt;
}
continue;
}
// The keyed look tracks sample on the trail's clip clock (see [`RibbonTrail::age`]):
// heights at edge-commit time (each edge keeps the width it was born with — the
// reference stores the vertex pair per edge), colour/alpha per frame for the whole strip.
*age += dt;
let ms = *age * 1000.0;
let h_above = def.height_above.sample_ms(ms).max(0.0);
let h_below = def.height_below.sample_ms(ms).max(0.0);
// 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
// session): the per-ribbon runtime byte `block+0xbc` IS the sampled `visibilityTrack`
@@ -469,16 +524,6 @@ 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;
@@ -606,52 +651,7 @@ impl Plugin for RibbonPlugin {
#[cfg(test)]
mod tests {
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"
);
}
use super::gravity_step;
/// 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
@@ -52,25 +52,35 @@ fn spawn_point_light(
world: Vec3,
color: [f32; 3],
intensity_scale: f32,
// The rooms this source belongs to, for a light inside a building — `None` outdoors. A torch in
// a culled room is one the reference never instantiated, so it lights nothing
// ([`crate::lighting::LightRooms`], decision 0689).
room: Option<crate::wmo_portal::WmoGroupVis>,
out: &mut Vec<Entity>,
) {
let e = commands
.spawn((
point_light(color, intensity_scale),
Transform::from_translation(world),
))
.id();
out.push(e);
let mut e = commands.spawn((
point_light(color, intensity_scale),
Transform::from_translation(world),
));
if let Some(room) = room {
e.insert(crate::lighting::LightRooms(room));
}
out.push(e.id());
}
/// Spawn a `PointLight` for each casting `type==1` (point) **M2** light of a prop at `transform` (the
/// campfire/torch/brazier/lantern sources). Position is M2 model space at rest (bone matrix identity at
/// rest), placed via the prop's transform like the emitters — a placed prop never animates its light
/// bone, so the rest pose is exact here (the entity path, whose bones DO move, rides joints instead).
///
/// `room` is the prop's own rooms for a WMO prop (`None` for an ADT map doodad) — taken straight off
/// the [`emitter_fade`] built for this placement, so the prop's mesh, its flames, its streamers and
/// its glow are all admitted or refused by one value.
pub(super) fn spawn_lights_for(
commands: &mut Commands,
lights: &[ModelLight],
transform: Transform,
room: Option<&crate::wmo_portal::WmoGroupVis>,
out: &mut Vec<Entity>,
) {
for l in lights.iter().map(|l| &l.def) {
@@ -78,7 +88,14 @@ pub(super) fn spawn_lights_for(
continue; // directional lights feed an ambient term; a static `0` visibility key is dark
}
let world = transform.transform_point(wow_to_bevy(l.position));
spawn_point_light(commands, world, l.diffuse_color, l.diffuse_intensity, out);
spawn_point_light(
commands,
world,
l.diffuse_color,
l.diffuse_intensity,
room.cloned(),
out,
);
}
}
@@ -88,18 +105,73 @@ pub(super) fn spawn_lights_for(
/// is WMO model space. The MOLT curve is the same fixed WoW falloff as the M2 path — byte-verified
/// (wow-re wmo-molt-runtime: the disk attenStart/End do not shape the GL falloff) and confirmed live in
/// the reference GL trace (committed c/l/q = 0/0.7/0.03, diffuse = colour × intensity, ambient 0).
///
/// A fixture belongs to the rooms whose **MOLR** names it (`group_light_refs`, per absolute group
/// index — the same list an interior GameObject's committed light folds), so a dungeon's brazier
/// stops lighting the hillside above it the moment the flood leaves its room. The refs are inverted
/// here, once per placement: a MOLT light no group names keeps no rooms and stays ungated, the same
/// fail-open arm the prop path's empty MODR list takes.
pub(super) fn spawn_wmo_lights_for(
commands: &mut Commands,
lights: &[WmoLight],
group_light_refs: &[Vec<u16>],
instance: Option<Entity>,
transform: Transform,
out: &mut Vec<Entity>,
) {
for l in lights {
for (i, l) in lights.iter().enumerate() {
if !l.is_omni() {
continue; // spot/directional/ambient MOLT types are not point sources
}
let i = i as u16;
let groups: std::sync::Arc<[u16]> = group_light_refs
.iter()
.enumerate()
.filter(|(_, refs)| refs.contains(&i))
.map(|(g, _)| g as u16)
.collect();
let world = transform.transform_point(wow_to_bevy(l.position));
spawn_point_light(commands, world, l.color, l.intensity, out);
spawn_point_light(
commands,
world,
l.color,
l.intensity,
instance
.filter(|_| !groups.is_empty())
.map(|instance| crate::wmo_portal::WmoGroupVis { instance, groups }),
out,
);
}
}
/// The **one draw-set gate a placed model's emitters share** — the quad clouds and the ribbon
/// trails alike, built once per placement so the two families can never be handed different
/// answers about whether their model is in the frame's scene.
///
/// `fade` is the owner's bounding sphere as `(radius, MODEL-space centre)`; the world centre is the
/// placement applied to it — the same point the doodad's own visibility gate measures to.
/// `instance` is the WMO placement this model's doodad set belongs to (`None` for an ADT map
/// doodad), carrying the exterior-window exemption for a prop of the building the camera is
/// standing in (0786). `groups` are the rooms of that placement which name this prop — `None` or
/// empty ⇒ **no room gate**, which is the mesh path's own `d.groups.is_empty()` arm, spelled once
/// here so the two cannot diverge (0689/1289). It is borrowed rather than owned because the shared
/// `Arc` is exactly what wants cloning, and an ADT map doodad — the tens-of-thousands case — must
/// not allocate an empty one per placement to say "I have no rooms".
pub(super) fn emitter_fade(
transform: Transform,
fade: (f32, Vec3),
instance: Option<Entity>,
groups: Option<&std::sync::Arc<[u16]>>,
) -> particles::EmitterFade {
let room = instance.zip(groups.filter(|g| !g.is_empty()));
particles::EmitterFade {
radius: fade.0,
center: transform.transform_point(fade.1),
instance,
room: room.map(|(instance, groups)| crate::wmo_portal::WmoGroupVis {
instance,
groups: groups.clone(),
}),
}
}
@@ -122,24 +194,16 @@ pub(super) fn spawn_wmo_lights_for(
/// `benilla-extract partslotscan` counts **947** such emitters across **184** models; the one that
/// surfaced it is the Blasted Lands lightning strike, whose whole burst lives in slot 1
/// (decision 0760, bug B63).
#[allow(clippy::too_many_arguments)] // a spawn plumbing fn: every arg is a distinct resource
pub(super) fn spawn_emitters_for(
commands: &mut Commands,
emitters: &[ModelEmitter],
transform: Transform,
joints: Option<&[Entity]>,
arm: Option<Entity>,
fade: (f32, Vec3),
// The WMO placement this model's doodad set belongs to (`None` for an ADT map doodad) — the
// exterior-window exemption for a prop of the building the camera is standing in (0786).
instance: Option<Entity>,
// The shared draw-set gate ([`emitter_fade`]), cloned onto every emitter this model spawns.
fade: &particles::EmitterFade,
out: &mut Vec<Entity>,
) {
// The owner doodad's fade sphere (radius, local centre) → the emitter's draw-set gate
// ([`particles::EmitterFade`]): world centre = the placement applied to the bbox centre, the
// same point the doodad's own visibility gate measures to.
let (fade_radius, fade_center) = fade;
let world_center = transform.transform_point(fade_center);
for em in emitters {
// Static placements (no anim host — the ~90% tier, and every capture) have no owner to
// follow; they despawn via the placement's entity list.
@@ -171,11 +235,7 @@ pub(super) fn spawn_emitters_for(
None => particles::EmitClock::Pinned,
},
) {
commands.entity(e).insert(particles::EmitterFade {
radius: fade_radius,
center: world_center,
instance,
});
commands.entity(e).insert(fade.clone());
out.push(e);
}
}
@@ -193,10 +253,11 @@ 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>,
// The SAME draw-set gate the quad clouds one function up are given ([`emitter_fade`]) — a trail
// is one of this model's emitters and is admitted by exactly the same five terms. It has to be
// carried explicitly because a trail rides a joint or a submesh, and neither of those states
// its owner model's fade sphere or its rooms ([`crate::ribbons::RibbonTrail::fade`]).
fade: &particles::EmitterFade,
) {
for rb in ribbons {
let (owner, use_pivot) = match joints.and_then(|js| js.get(rb.def.bone as usize)).copied() {
@@ -221,7 +282,7 @@ pub(super) fn spawn_ribbons_for(
),
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
None,
room.clone(),
Some(fade.clone()),
);
}
}
@@ -9,7 +9,9 @@ pub(crate) mod prop_light;
pub use assemble::spawn_model_entities;
pub use fx::point_light;
use fx::{spawn_emitters_for, spawn_lights_for, spawn_ribbons_for, spawn_wmo_lights_for};
use fx::{
emitter_fade, spawn_emitters_for, spawn_lights_for, spawn_ribbons_for, spawn_wmo_lights_for,
};
use std::sync::Arc;
use std::time::Instant;
@@ -214,14 +216,17 @@ pub(super) fn spawn_loaded_placements(
.id(),
);
}
// One gate for both emitter families. `None`/empty: an ADT map doodad
// belongs to no building, so neither the window exemption nor the room
// term has anything to key on.
let fade = emitter_fade(p.transform, (radius, center), None, None);
spawn_emitters_for(
&mut commands,
&m.emitters,
p.transform,
host.as_ref().map(|h| h.joints.as_slice()),
host.as_ref().and_then(|h| h.arm),
(radius, center),
None, // an ADT map doodad belongs to no building
&fade,
&mut ents,
);
spawn_ribbons_for(
@@ -231,9 +236,9 @@ 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
&fade,
);
spawn_lights_for(&mut commands, &m.lights, p.transform, &mut ents);
spawn_lights_for(&mut commands, &m.lights, p.transform, None, &mut ents);
tag_world_object(
&mut commands,
&ents,
@@ -449,7 +454,14 @@ pub(super) fn spawn_loaded_placements(
// Interior MOLT lights (forge fire, inn fireplaces, chapel candles) — the radiating
// sources that light nearby NPCs/doodads AND the building's own walls/floor over
// their baked MOCV (decision 0273).
spawn_wmo_lights_for(&mut commands, &m.lights, p.transform, &mut ents);
spawn_wmo_lights_for(
&mut commands,
&m.lights,
&m.group_light_refs,
p.portal_instance,
p.transform,
&mut ents,
);
tag_world_object(
&mut commands,
&ents,
@@ -606,14 +618,23 @@ pub(super) fn spawn_loaded_placements(
.id(),
);
}
// One gate for both emitter families: the prop rides its building's exterior-window
// exemption AND the portal PVS of the rooms that name it — the same `WmoGroupVis` the
// submeshes above are culled by, so a prop's mesh, its flames and its streamers are
// admitted or refused together (decisions 0786 / 0689 / 1289).
let fade = emitter_fade(
d.transform,
(radius, center),
portal_instance,
Some(&d.groups),
);
spawn_emitters_for(
&mut commands,
&m.emitters,
d.transform,
host.as_ref().map(|h| h.joints.as_slice()),
host.as_ref().and_then(|h| h.arm),
(radius, center),
portal_instance, // a WMO prop rides its building's exemption
&fade,
&mut ents,
);
spawn_ribbons_for(
@@ -623,16 +644,15 @@ 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(),
}),
&fade,
);
spawn_lights_for(
&mut commands,
&m.lights,
d.transform,
fade.room.as_ref(), // the prop's glow rides its rooms like its mesh (0689)
&mut ents,
);
spawn_lights_for(&mut commands, &m.lights, d.transform, &mut ents);
tag_world_object(
&mut commands,
&ents,
@@ -153,6 +153,36 @@ impl WmoGroupVis {
}
}
/// **Is a room-bound RIDER drawn this frame?** — [`WmoGroupVis::drawn_by`] resolved for the lanes
/// that carry their rooms *by value* rather than as a component, because their entity is not a
/// model submesh: a prop's particle clouds and ribbon trails
/// ([`crate::particles::EmitterFade::room_admitted`]) and a building's point-light sources
/// ([`crate::lighting::LightRooms`]).
///
/// Every one of them exists for the same reason. The reference instantiates a WMO's furniture out
/// of each **visible** group's own MODR list (`0x695aa0` from the visible-group walk `0x698720`,
/// decision 0689), so a prop in a culled room is never created and has nothing to tick, draw or
/// light with. We create props once and cull them per frame, so each rider has to ask for itself —
/// and this is where the question is spelled, once, for all of them.
///
/// - **No rooms** — admitted. An ADT map doodad, a creature, a held item: nothing claims it.
/// - **Rooms, placement resident** — `drawn_by`: the identical predicate the owner's own submeshes
/// are culled by, so a model and its riders can never disagree about which frame they are in.
/// - **Rooms, placement gone** — refused, where `drawn_by` itself fails OPEN. Deliberate, and the
/// one asymmetric arm: a rider outlives its building by the frame between the placement
/// despawning and the cascade reaching it, and one frame of an additive strip — or a torch —
/// hanging where a building used to be is a bright artefact, where one frame of a missing one is
/// nothing at all. [`crate::wmo_sky`]'s skybox resolve refuses on the same asymmetry, and 1276 is
/// the general rule: an unknown with a testable answer must be tested, but where the error
/// directions are lopsided, take the cheap one.
pub fn room_admits(room: Option<&WmoGroupVis>, instance: Option<&WmoPortalInstance>) -> bool {
match (room, instance) {
(None, _) => true,
(Some(r), Some(inst)) => r.drawn_by(inst),
(Some(_), None) => false,
}
}
/// One placed WMO building's portal-cull state. Spawned alongside the building's geometry; despawns
/// with the placement (it's pushed into the placement's entity list).
#[derive(Component)]