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:
@@ -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(®istry_commands());
|
||||
script
|
||||
.run(
|
||||
r#"CAPTURED = nil
|
||||
function KeyBindings_OnHostKey(chord) CAPTURED = chord end
|
||||
BenillaBindCapture(true)"#,
|
||||
)
|
||||
.expect("arm");
|
||||
let mut app = vm_harness(script);
|
||||
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: 1.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(
|
||||
lua_str(&app, "tostring(CAPTURED)"),
|
||||
"MOUSEWHEELUP",
|
||||
"a wheel notch while armed is a binding key"
|
||||
);
|
||||
assert!(
|
||||
!state(&app).fired(cmd::CAMERA_ZOOM_IN),
|
||||
"the armed seam swallows the notch — it must not also zoom"
|
||||
);
|
||||
|
||||
// Down, and a modified notch: the canonical prefix order rides the same path.
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: -1.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(lua_str(&app, "tostring(CAPTURED)"), "MOUSEWHEELDOWN");
|
||||
press_key(&mut app, KeyCode::ShiftLeft);
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: 1.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(lua_str(&app, "tostring(CAPTURED)"), "SHIFT-MOUSEWHEELUP");
|
||||
}
|
||||
|
||||
/// **The whole wheel-bind path in one harness** (B265): the real Keybindings page, a capsule
|
||||
/// armed by a real click, a real notch fed the way winit feeds it, and then the bound chord
|
||||
/// dispatching. The page's own tests call `KeyBindings_OnHostKey` by hand and this module's
|
||||
/// tests carry no page — between them the join was never asserted.
|
||||
#[test]
|
||||
fn a_wheel_notch_binds_through_the_real_page_and_then_dispatches() {
|
||||
let by_name =
|
||||
|n: &str| Cmd(SPECS.iter().position(|s| s.name == n).expect("registered") as u16);
|
||||
let mut s = crate::ui_script::keybindings_tests::harness();
|
||||
crate::ui_script::keybindings_tests::on_page(&mut s);
|
||||
const ROW: &str = "OptionsFrameContainerBodyKeybindingsRow";
|
||||
// Expand Movement and arm JUMP's first capsule — JUMP is the classic wheel bind, and one
|
||||
// of the 1.12 commands that is NOT `runOnUp`, so the reference accepts the wheel on it.
|
||||
s.run(&format!("{ROW}1Header:Click()")).expect("expand");
|
||||
s.run(&format!("{ROW}9Key1Button:Click()")).expect("select");
|
||||
assert_eq!(
|
||||
s.eval::<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(®istry_commands());
|
||||
script.register_addon_bindings(
|
||||
"ProbeAddon",
|
||||
&benilla_ui::bindings_xml::parse(PROBE_BINDINGS).expect("well-formed"),
|
||||
);
|
||||
script.seed_binding_set(
|
||||
1,
|
||||
Some(vec![(
|
||||
"PROBEHOLD".to_string(),
|
||||
vec!["MOUSEWHEELUP".to_string()],
|
||||
)]),
|
||||
);
|
||||
script.load_binding_set(1);
|
||||
let mut app = vm_harness(script);
|
||||
|
||||
app.world_mut().write_message(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: 1.0,
|
||||
window: Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(lua_count(&app, "PROBE_DOWN"), 1, "the notch's press half");
|
||||
assert_eq!(
|
||||
lua_count(&app, "PROBE_UP"),
|
||||
1,
|
||||
"…and its release half, in the same frame — a notch has no key left to lift"
|
||||
);
|
||||
assert_eq!(lua_str(&app, "PROBE_LAST"), "up");
|
||||
// Nothing is left latched: a wheel latch that outlived its notch would hold the down
|
||||
// state forever, with no key to press to end it.
|
||||
assert!(app.world().resource::<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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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![]);
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user