Merge upstream main (samwhosung/benilla edec5b22) into lighting

Ports the lighting settings into upstream's single settings registry and the lighting plugins
into game_plugins.rs. 1108 world+formats tests green; world scenes (inn, Northshire dusk,
Stormwind) render clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-17 21:17:41 +01:00
co-authored by Claude Fable 5.1
497 changed files with 25335 additions and 6061 deletions
@@ -534,7 +534,11 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() {
fn the_roster_seat_names_the_character_the_addons_will_meet() {
let seat = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
assert_eq!(seat.name.as_deref(), Some("Harness"));
assert_eq!(seat.level, 60);
// **0, not 60 — the reference's answer, byte-verified** (decision 2263). `UnitLevel 0x517fc0`
// carries no `"player"` fast path at all: it resolves the token, misses (no object, no roster
// record for a zero GUID) and reaches `0x51813e push 0; push 0` — the NUMBER 0, one return.
// The roster's real level arrives with the descriptor, within the second.
assert_eq!(seat.level, 0);
assert_eq!(seat.race_file.as_deref(), Some("Human"));
assert_eq!(seat.class_file.as_deref(), Some("WARRIOR"));
assert_eq!(seat.sex, 2, "the wire's 0 is UnitSex's 2");
@@ -543,7 +547,17 @@ fn the_roster_seat_names_the_character_the_addons_will_meet() {
Some("Alliance"),
"nil here is 24 corpus addons stopping on AceDB-2.0's file-scope concatenation"
);
assert!(seat.exists && seat.is_player);
// **`exists` is false, and that is the correction 2263 made** (it was `true` from 1230 until
// then). `UnitExists 0x515fb0` has no fast path either; its resolver reads the GUID out of the
// OBJECT (`0x515994`), so with none it holds `0:0`, and the roster fallback `0x491900` bails
// on a zero GUID at `0x4e80aa je` BEFORE fetching the active player — so the `0 == 0` that
// would answer "that's me" never runs, and `0x516001` pushes nil.
//
// Safe to correct only *because* of the record above: 1230 seated a whole unit in order to
// deliver a name at addon file scope, and the name no longer needs a unit to exist. The rest
// of the seat is untouched and still load-bearing — the faction side below above all.
assert!(!seat.exists);
assert!(seat.is_player);
// Deliberately NOT invented — the descriptor says these, within the second.
assert_eq!((seat.health, seat.max_health), (0, 0));
File diff suppressed because it is too large Load Diff
@@ -69,7 +69,6 @@ fn harness() -> UiScript {
/// Build an [`AuraState`] as the feed would push it. `expiration_time` 0 = permanent (no wire
/// duration — the reference's "until cancelled"); the `GetTime()` clock starts at 0 in the harness.
#[allow(clippy::too_many_arguments)]
fn aura(
spell_id: u32,
name: &str,
+79 -2
View File
@@ -181,17 +181,32 @@ fn failed_cast_turns_red_holds_then_fades() {
fn channel_counts_down_not_up() {
let mut s = harness();
// SPELLCAST_CHANNEL_START(ms, name) — args reversed vs START, per the reference contract.
// The name is whatever `ui_cast::channel_start_args` composed; for all but nine of the 323
// channeled rows that is the literal word, which is what the feed hands Starshards too.
s.fire_event(
"SPELLCAST_CHANNEL_START",
vec![
ScriptValue::Int(6000),
ScriptValue::Str("Starshards".into()),
ScriptValue::Str("Channeling".into()),
],
);
assert!(s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return CastingBarText:GetText()").unwrap(),
"Starshards"
"Channeling"
);
// **In 1.12 the channel bar is ORANGE** — the same `SetStatusBarColor(1.0, 0.7, 0.0)` the cast
// bar takes (stock `CastingBarFrame.lua` l.76 vs l.21, byte-identical to the copy in the
// player's `patch.MPQ`); green is the COMPLETION flash and nothing else. A channel is told
// apart by draining instead of filling, and by its label. **Classic Era's channel bar IS
// green** (`CastingBarType.Channel`'s `classicFillColor = CASTBAR_CLASSIC_GREEN`) — a real
// behaviour of a different client, which is why this is a gate and not a comment: if we ever
// take Era's colour it is a deliberate deviation that has to come here first (decision 2284).
let (r, g, b) = bar_color(&s);
assert!(
(r - 1.0).abs() < 1e-6 && (g - 0.7).abs() < 1e-6 && b.abs() < 1e-6,
"a channel opens orange, exactly like a cast (got {r} {g} {b})"
);
let full = bar_value(&s);
@@ -226,6 +241,68 @@ fn channel_counts_down_not_up() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A completed channel closes on the BAR's own clock, because the server's stop runs a second
/// late** — and that is the path every naturally-finished channel in the game takes.
///
/// vmangos does not send `MSG_CHANNEL_UPDATE(0)` when a channel simply runs out.
/// `Spell::SendChannelUpdate(0, interrupted=false)` schedules a `ChannelResetEvent` **+1000 ms**
/// ("Else, we have some visual bugs (arcane projectile, last tick)"), and only that event's
/// `CancelSpellChannelingAnimationInstantly()` finally emits the packet. The *interrupt* path calls
/// the same function inline, which is why a broken channel stops at once and a finished one does
/// not.
///
/// So for a whole second after the fill empties there is no stop edge at all, and the stock
/// `OnUpdate` is what ends it: once `GetTime()` reaches `endTime` it clamps, `time == this.endTime`
/// trips, and the frame hands itself to `fadeOut` with **no green flash** — a finished channel
/// fades, where a cast completes green. The late packet then lands on a hidden frame and both of
/// `CastingBarFrame_OnEvent`'s stop-arm guards reject it.
///
/// Worth a test of its own because nothing else exercises it: the sibling test above fires
/// `SPELLCAST_CHANNEL_STOP` by hand, which is the *interrupt* timing, not this one.
#[test]
fn a_finished_channel_fades_on_its_own_clock_and_the_late_stop_is_inert() {
let mut s = harness();
s.fire_event(
"SPELLCAST_CHANNEL_START",
vec![
ScriptValue::Int(1000),
ScriptValue::Str("Channeling".into()),
],
);
assert!(s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap());
// The whole second the channel runs: still up, still orange, no stop packet has been sent.
for _ in 0..9 {
s.tick(0.1);
}
assert!(
s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
"still channelling at 0.9 s of a 1 s channel"
);
let (r, g, b) = bar_color(&s);
assert!(
(r - 1.0).abs() < 1e-6 && (g - 0.7).abs() < 1e-6 && b.abs() < 1e-6,
"never flashes green — a finished channel fades, it does not complete (got {r} {g} {b})"
);
// Past endTime with nothing from the server: the frame ends itself.
for _ in 0..40 {
s.tick(0.1);
}
assert!(
!s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
"the bar closed on its own clock, a full second before the server says so"
);
// ~1 s after the fill emptied, the deferred ChannelResetEvent finally fires.
s.fire_event("SPELLCAST_CHANNEL_STOP", vec![]);
assert!(
!s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
"the late stop lands on a hidden frame and does nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The draw list ────────────────────────────────────────────────────────────────────────────────
const FLASH: &str = "UI-CastingBar-Flash";
@@ -387,7 +387,6 @@ fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
/// `booths.panes` is the one exception, because [`convert_entry`] writes it through the shared
/// bridge; re-adding the same tokens is idempotent, so a verify run's panes end up where the full
/// path would have put them anyway.
#[allow(clippy::too_many_arguments)]
fn verify_splice(
prev: &GateInputs,
quads: &UiQuads,
@@ -522,7 +521,6 @@ pub(crate) fn seat_text_measurer(script: &mut UiScript, atlas: &UiFontAtlas, sea
/// p90 of 16 px of lag per frame at 640x360 while a guard walked (the `vpl` trace, 2026-09-10), and
/// proportionally more at a real window. That is the director's "way more jittered when the
/// creature is moving".
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
pub(super) fn tick_script(
script: Option<NonSendMut<UiScript>>,
window: Query<&Window, With<PrimaryWindow>>,
@@ -776,7 +774,6 @@ pub(super) fn tick_script(
/// walked after that camera exists. The re-resolve is what makes the plates' fresh anchors real —
/// it is the layout ledger's incremental pass, so on a frame where nothing but the plates moved it
/// solves the plates and nothing else.
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
pub(super) fn paint_script(
script: Option<NonSendMut<UiScript>>,
window: Query<&Window, With<PrimaryWindow>>,
@@ -1381,7 +1378,6 @@ pub(super) fn paint_script(
/// splice both call this, which is what makes the splice's output equal the full path's by
/// construction. An arm is splice-eligible only if it writes nothing but `out` — keep
/// [`splice_simple`] in agreement when an arm's side effects change.
#[allow(clippy::too_many_arguments)] // the conversion's whole environment, threaded explicitly
fn convert_entry(
eq: benilla_ui::script::ExtractedQuad,
s: f32,
@@ -436,8 +436,13 @@ fn describe(frame: &str, flag: Flag, ours: &str, theirs: &str) -> String {
/// `parent="UIParent"` back on `BlackoutWorld` and it names that.
#[test]
fn the_shipped_frames_carry_the_references_flags() {
// Two gates, because the corpus and the manifest are two assets: the extracted reference
// dir below, and the chain `load_default_ui` reads — under `WOW_DATA=` (1451) the first is
// still there and the second is not.
let _data = benilla_formats::wow_data_or_skip!();
let Some(reference) = reference_frames() else {
return; // no install — the same skip every client-data test here takes
eprintln!("skipping: no extracted reference FrameXML (WoW/_extracted_framexml)");
return;
};
let nesting = reference_nesting().expect("the same corpus the frames came from");
assert!(
+89 -26
View File
@@ -46,7 +46,7 @@ pub(crate) fn setup_script(world: &mut World) {
/// down at `0x490c97`). Because [`end_ui_session`] routes every login through here, every session's
/// VM gets the registry and every `inherits="GameFontNormal"` in FrameXML resolves. A design that
/// dropped the VM and skipped index 0 on the in-game load would fail exactly there — verified in
/// wow-re's §5 for this change (1291).
/// wow-re's §5 for this change (2277).
fn install_boot_vm(world: &mut World) {
let mut script = match UiScript::new() {
Ok(s) => s,
@@ -238,7 +238,7 @@ pub(crate) fn arm_entry_ui_load(world: &mut World) {
/// phase can write CVars — the character screen's AddOns panel is the live case, a *Load out of
/// date AddOns* click — and `sync_cvars` (1291) registers them onto whichever VM is in the world,
/// so this is the last moment that table exists. Folded into the persist state, the
/// `set_cvar_saved_base` + `register_cvars` pair in the load below starts the new VM at the
/// `set_cvar_saved_base` + `seed_cvars` pair in the load below starts the new VM at the
/// player's value. That is exactly the route `/reload` has always taken through
/// [`end_ui_session`]; 2226 makes the first login take it too.
///
@@ -483,15 +483,19 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// gets a turn — so this edge would load a client with no CVar table at all. Nothing had ever
// noticed, because until now no interface file read a CVar at load.
//
// The two lines are `sync_cvars`'s own first two, in its own order, and the order is
// load-bearing: the saved config goes in FIRST so registration starts each key at the player's
// value rather than the factory one (1291) — reversed, a reload would quietly reset every
// knobless CVar to its default. `sync_cvars` still does its full knob-derived pass on the next
// `Update`; both calls are idempotent, and a re-register never clobbers a live value.
if let Some(persist) = world.get_resource::<crate::cvars::CvarPersist>() {
script.set_cvar_saved_base(persist.saved_base());
// The two lines are `sync_cvars`'s own seed, in its own order, and the order is
// load-bearing: the file's unclaimed entries go in FIRST so an addon's `RegisterCVar` starts
// its key at the player's value rather than the declared one (1291); then the registry's
// whole table at its live values (2303) — the store that outlived the last VM. `sync_cvars`
// still runs its own per-VM seed on the next `Update`; both are idempotent. A world with no
// registry (a bare test app) gets the registered defaults.
match world.get_resource::<crate::cvars::Cvars>() {
Some(cvars) => {
script.set_cvar_saved_base(cvars.orphans());
script.seed_cvars(cvars.vm_seed());
}
None => script.register_cvars(crate::cvars::registered_pairs()),
}
script.register_cvars(crate::cvars::registered_pairs());
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
@@ -502,6 +506,21 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
script.set_realm_name(&realm);
// …and so does the PLAYER, for the same reason and with more riding on it — see
// [`seat_from_roster`], which is where the why lives.
//
// **The player RECORD first, and separately** (decisions 2261/2263). The reference keeps the
// local player's name, race, class and gender somewhere no cache, feed or object can reach — a
// copy of the char-enum row written at the Enter World commit (`0x5abd9e`) and never cleared —
// and `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only that, unconditionally, even after
// the descriptor exists. The snapshot below is the descriptor's stand-in until the real one
// streams in and is replaced when it does; the record outlives every push. Seeded here because
// our VM is rebuilt per login (1290) while the reference's record simply persists, so each new
// VM has to be told once — before the addon walk, like the realm.
if let Some(record) = world
.get_resource::<crate::char_select::Roster>()
.and_then(record_from_roster)
{
script.set_player_record(record);
}
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
@@ -516,14 +535,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// character select reached here inside the save debounce; the fold carries that click now, by
// the route the reload path always used. Absent both (a bare test world) is the registrar
// default: check ON.
let version_check = script
.cvar("checkAddonVersion")
.map(|v| v != "0")
.unwrap_or_else(|| {
world
.get_resource::<crate::cvars::CvarPersist>()
.is_none_or(crate::cvars::CvarPersist::addon_version_check)
});
let version_check = world
.get_resource::<crate::cvars::Cvars>()
.is_none_or(crate::cvars::Cvars::addon_version_check);
// **…and the rest of the same class** (decision 2241). Each of these was a per-VM claim in
// `Update`, which answers *which* VM and not *when inside its life* — and every one of them
// backs a Lua getter the load burst below reads:
@@ -660,6 +674,43 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
}
}
/// The wire's gender byte (0 male, 1 female) on `UnitSex`'s own 2/3 scale — the one mapping, so
/// the record and the snapshot can never disagree about which is which.
fn roster_sex(gender: u8) -> u8 {
match gender {
0 => 2,
1 => 3,
_ => 0,
}
}
/// **The local player record the UI loads under** — our copy of the char-enum row the reference
/// copies at the Enter World commit (decision 2263; the bytes are on
/// [`benilla_ui::script::PlayerRecord`]).
///
/// The same roster row [`seat_from_roster`] builds the `"player"` *snapshot* from, and
/// deliberately the same `race_names`/`class_names` lookups: the reference resolves the record's
/// race and class bytes through `ChrRaces`/`ChrClasses` at call time, so resolving them once here
/// is the same answer, and sharing the lookup is what stops the record and the snapshot drifting
/// into two opinions about what race 4 is.
///
/// **The level is not here, and that is byte-verified, not an omission** — the reference's record
/// carries a level at `+0x108` and ships an accessor for it (`0x5abe00`) that *nothing calls*, so
/// `UnitLevel("player")` reads the descriptor.
pub(crate) fn record_from_roster(
roster: &crate::char_select::Roster,
) -> Option<benilla_ui::script::PlayerRecord> {
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
let class = crate::ui_unit::class_names(row.class);
Some(benilla_ui::script::PlayerRecord {
name: row.name.clone(),
race: race.map(|(n, f)| (n.to_string(), f.to_string())),
class: class.map(|(n, f)| (n.to_string(), f.to_string())),
sex: roster_sex(row.gender),
})
}
/// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in
/// flight — `None` when there is no pick (a capture, a scenario, a test world).
///
@@ -693,20 +744,32 @@ pub(crate) fn seat_from_roster(
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
let class = crate::ui_unit::class_names(row.class);
let sex = roster_sex(row.gender);
Some(benilla_ui::script::UnitState {
exists: true,
// **`exists` is FALSE, and the level is 0 — the reference's answers, byte-verified**
// (decision 2263). `UnitExists("player")` `0x515fb0` has no fast path: it resolves the
// token, and the resolver reads the GUID out of the OBJECT (`0x515994`), not out of
// `[mgr+0xc0]` — so with no object it holds `0:0`. The roster fallback `0x491900` then
// bails on a zero GUID at `0x4e80aa je` *before* it fetches the active player, so the
// `0 == 0` that would otherwise answer "yes, that's me" is never reached: `0x516001
// lua_pushnil`. `UnitLevel 0x517fc0` likewise carries no `"player"` compare at all and
// reaches its total-miss arm `0x51813e push 0; push 0` — the **number 0**, not nil. The
// two misses are deliberately asymmetric and a client that modelled "no data" uniformly
// would be wrong on one of them (wow-re `ui/scratch/unit-verbs-before-player-object.md`
// §2-§3; it also corrected that repo's own `main`, which had published the opposite).
//
// This seat is no longer what makes the player *answerable* — the record above is — so it
// no longer has to claim a unit exists in order to deliver a name. What it still carries
// is the handful of fields the corpus reads at file scope that are neither the record's
// nor the descriptor's to say yet, the faction side below chief among them.
exists: false,
name: Some(row.name.clone()),
level: u32::from(row.level),
race: race.map(|(n, _)| n.to_string()),
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| n.to_string()),
class_file: class.map(|(_, f)| f.to_string()),
// The wire's 0/1 on `UnitSex`'s 2/3 scale — `ui_unit::snapshot`'s own mapping.
sex: match row.gender {
0 => 2,
1 => 3,
_ => 0,
},
sex,
is_player: true,
player_controlled: true,
// Nil here is not "no faction", it is a state a player character cannot be in, and
@@ -793,7 +856,7 @@ pub(crate) fn arm_leaving_world_on_self_create(
///
/// **The last step is not "destroy the Lua state"** — which is what three of wow-re's own notes
/// said, until this client's teardown made the question load-bearing and a §5 cross-check settled
/// it (`system/ui/scratch/lua-state-lifecycle.md`; 1291). `0x490c97` is the frame-script owner's
/// it (`system/ui/scratch/lua-state-lifecycle.md`; 2277). `0x490c97` is the frame-script owner's
/// scalar-deleting destructor — the widget tree and the native virtual-font registry — and the Lua
/// state outlives it. The state is closed and re-opened at `0x703b80`, which reaches `InitLua` by a
/// **tail-`jmp`** (`0x703b8e`) rather than a call, which is exactly why a call-census missed it.
+69 -12
View File
@@ -281,13 +281,31 @@ pub(crate) struct UiPassState {
#[derive(Resource, Default)]
pub(crate) struct PointerOverUiPanel(pub(crate) bool);
/// The player-UI FEED phase — every push into the VM that this frame's tick must see: the
/// snapshot feeds, the one-shot drains that `fire_event`, the per-VM seeds. Chained
/// `UiFeed` → [`UiInput`] → [`UiPaint`] and ordered after [`WorldStage::Net`] (decision 2304), so
/// a feed reads what this frame's packets did, and a handler its event fires reads what every
/// other feed pushed — the reference's own frame, where packet dispatch precedes every
/// `FrameScript_SignalEvent` and both precede `OnUpdate`. The failure the order closes was found
/// in the unit feed first ([`crate::ui_unit::UnitFeed`]'s doc): unordered, `apply_net_updates`
/// could land between two feeds, and a synchronous handler fired by the later one re-read the
/// earlier one's pre-mutation push. **No run condition on the set** — whether a feed may run
/// before the in-game interface exists is a judgement per feed (2232), and the ones that may not
/// ride `UnitFeed`, a gated sub-phase of this one. Every system holding the VM in `Update`
/// declares its side of the tick — this set, or `.after(UiInput)` — and
/// `game_plugins::schedule_tests` holds it on the built schedule.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiFeed;
/// The player-UI PAINT pass — the quad walk ([`extract::paint_script`]), ordered after the world's
/// camera update so that world-anchored widgets are drawn from this frame's camera (decision 2168).
/// The last of the three UI phases (see [`UiFeed`]).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiPaint;
/// The player-UI input pass — hit-testing + handler firing. Ordered before [`WorldStage::Input`] so
/// the [`PlayerUiHover`] it produces is folded into `PointerOverUi` before `player::control` reads it.
/// The player-UI input pass — the tick, hit-testing + handler firing. Ordered before
/// [`WorldStage::Input`] so the [`PlayerUiHover`] it produces is folded into `PointerOverUi` before
/// `player::control` reads it; the middle of the three UI phases (see [`UiFeed`]).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiInput;
@@ -403,8 +421,31 @@ pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 {
/// tick → resolve → extract pipeline into [`UiQuads`], plus the input pass ([`feed_ui_input`]).
pub(crate) struct UiScriptPlugin;
/// `uiScale`'s change callback (decision 2303): the dial's own `[0.5, 1.5]` clamp.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut scale: ResMut<UiScaleCvar>) {
if ev.is("uiScale") {
scale.0 = ev.num().clamp(0.5, 1.5);
}
}
/// `/console reloadUI` — the reference's own console command, and the same deferred rebuild
/// `ReloadUI()` and `/reload` queue (decision 1291): through the session seam, run by
/// [`run_pending_reload`] at the top of the next frame.
fn console_reload_ui(world: &mut World, _args: &str) -> Vec<String> {
match world.get_non_send_resource_mut::<UiScript>() {
Some(mut script) => {
script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi);
Vec::new()
}
None => vec!["reloadUI: no interface to reload".to_string()],
}
}
impl Plugin for UiScriptPlugin {
fn build(&self, app: &mut App) {
use crate::console::ConsoleCommandApp;
app.add_observer(on_cvar);
app.console_command("reloadUI", "Reload the interface.", console_reload_ui);
// **The quit root of the shutdown tail, on the exit edge** (decision 1528). It was
// `.add_systems(Update, shutdown_on_exit)` below, which cannot see the `AppExit` a player
// produces — the close button's is written in `PostUpdate` — so quitting from in-world
@@ -474,29 +515,40 @@ impl Plugin for UiScriptPlugin {
PreUpdate,
(run_pending_reload, lifecycle::run_pending_entry_load).chain(),
)
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
// **The three UI phases, in order** (decision 2304): every push after the net
// drain, then the tick, then the paint. `UiFeed`'s own doc has the why; `UiInput`
// sits before the world's input stage so the hover it produces reaches the pointer
// arbiter in time (its doc).
.configure_sets(
Update,
(
UiFeed.after(WorldStage::Net),
UiInput.before(WorldStage::Input),
UiPaint,
)
.chain(),
)
// `GetFramerate()`'s host half (decision 1195), in the feed phase so an `OnUpdate`
// handler reads this frame's number rather than the previous one's.
.add_systems(Update, feed_framerate.before(UiInput))
.add_systems(Update, feed_framerate.in_set(UiFeed))
// `tick_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// chain (also required because both take the single `NonSend` VM). The pair runs before
// `WorldStage::Input` so the hover result reaches the pointer arbiter in time. The input
// pass is in-world only (decision 0193): the character-select glue screen owns the
// pointer + keyboard there (its exit edge resets the latches this pass normally drives).
// chain (also required because both take the single `NonSend` VM). The input pass is
// in-world only (decision 0193): the character-select glue screen owns the pointer +
// keyboard there (its exit edge resets the latches this pass normally drives).
.init_resource::<UiPassState>()
.init_resource::<PointerOverUiPanel>()
.add_systems(
Update,
(
extract::tick_script,
input::feed_ui_input.run_if(in_state(crate::char_select::ClientState::InWorld)),
input::feed_ui_input.in_set(crate::char_select::InWorldGated),
)
.chain()
.in_set(UiInput)
// The binding dispatch (0997) runs in this same set, after the feed: it
// reads the capture gate the feed just wrote, so a key a focused box
// consumed this frame never also fires a binding.
.before(crate::bindings::BindingSet)
.before(WorldStage::Input),
.before(crate::bindings::BindingSet),
)
// **The paint half, after the camera** (decision 2168): the quad walk runs once the
// world's own update has happened, so a widget anchored to the world — the nameplates,
@@ -527,8 +579,8 @@ impl Plugin for UiScriptPlugin {
app.add_systems(
Update,
demo_unit_feed
.in_set(UiFeed)
.after(UnitFeed)
.before(UiInput)
.run_if(capture_ui_active),
);
}
@@ -1174,6 +1226,11 @@ mod quest_timer_tests;
#[cfg(test)]
mod battlefield_tests;
/// The battle map — the reference's `Blizzard_BattlefieldMinimap` addon, demand-loaded the way
/// SHIFT-M loads it, over the overlay/POI/position/arrow verbs it shares with the world map.
#[cfg(test)]
mod battlefield_minimap_tests;
#[cfg(test)]
mod tutorial_tests;
+1 -57
View File
@@ -245,63 +245,7 @@ mod tests {
("cursor.rs", "last_ptr"),
];
/// Every `.rs` file under `root`, recursively.
fn rust_files(root: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
out
}
/// The parenthesised parameter list of every `fn` in `text`, by paren matching — so the scan
/// sees a signature rather than a whole body, and a `Local` in some unrelated expression
/// cannot be mistaken for a system parameter.
fn fn_parameter_lists(text: &str) -> Vec<&str> {
let bytes = text.as_bytes();
let mut out = Vec::new();
for (i, _) in text.match_indices("fn ") {
// `fn` must start a word: `…_fn (` and `Fn(` are not declarations.
if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
continue;
}
let Some(open) = text[i..].find('(').map(|o| i + o) else {
continue;
};
// A generic parameter list can carry parens, but never before the argument list's `(`
// in the shapes this codebase writes; a `<` with an unbalanced `(` would just scan on.
let mut depth = 0usize;
let mut close = None;
for (j, c) in text[open..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
close = Some(open + j);
break;
}
}
_ => {}
}
}
if let Some(close) = close {
out.push(&text[open + 1..close]);
}
}
out
}
use crate::test_support::{fn_parameter_lists, rust_files};
/// Every `name: Local<Ty>` in a parameter list, as `(name, Ty)` — angle-bracket matched, so a
/// nested generic comes back whole.
@@ -565,3 +565,44 @@ fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
s.errors()
);
}
/// **The CORNER arm must end up owned too — the other half of the existence gate** (decision 2259).
///
/// The reference's corner arm (`0x492a42`) writes no owner itself; the owner is restored purely by
/// the `+0x444` handler, `OnTooltipSetDefaultAnchor` → `GameTooltip_SetDefaultAnchor(this,
/// UIParent)` → `SetOwner(UIParent, "ANCHOR_NONE")`. And the owner really is 0 on the way in:
/// `Tooltip::Hide 0x530a60` *is* `SetOwner(NULL, 0, 0, 0)`, so every hover starts un-owned.
///
/// That makes this test the precondition for narrowing the placement fork at all. Moving an object
/// from the cursor arm to the corner arm is only safe while the corner arm produces an OWNED,
/// SHOWN plate — otherwise those objects would build their lines and then hide, which is precisely
/// the failure 2255 had just fixed on the cursor arm.
#[test]
fn a_corner_seated_gameobject_plate_is_owned_and_shown() {
let mut s = harness(&[]);
assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None));
let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap();
assert!(owned, "the corner plate is owned; errors: {:?}", s.errors());
let shown: bool = s
.eval("return GameTooltip:IsShown() and true or false")
.unwrap();
assert!(shown, "and it is on screen; errors: {:?}", s.errors());
}
/// And it survives the same addon hook the cursor arm had to: `!Questie`'s `OnShow` handler ends in
/// `GameTooltip:Show()`, and `:Show()` is the existence gate `0x530a80`.
#[test]
fn a_corner_seated_gameobject_plate_survives_an_addons_on_show_hook() {
let mut s = harness(&[]);
s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#)
.unwrap();
assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None));
let shown: bool = s
.eval("return GameTooltip:IsShown() and true or false")
.unwrap();
assert!(
shown,
"the corner plate is still up; errors: {:?}",
s.errors()
);
}
@@ -136,7 +136,6 @@ fn text_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
/// One service in a named skill line, spelling out every field so the intent is legible at the call
/// site.
#[allow(clippy::too_many_arguments)]
fn service(
spell_id: u32,
name: &str,
@@ -1657,3 +1657,68 @@ ScreenProbeHeight = GetScreenHeight()
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **The entry load seeds the player record, not just the snapshot** (decisions 2261/2263).
///
/// [`super::seat_from_roster`]'s `"player"` push is the descriptor's stand-in and is *replaced*
/// the moment the real one streams in — which is how decision 2260's nameless push reached
/// `UnitName("player")`. The buffer is seeded beside it, from the same roster row, and the verb
/// reads only that; so the token can be replaced by a nameless snapshot or removed outright and
/// the name still answers, exactly as the reference's never-cleared `0xc27d88` does.
#[test]
fn the_entry_load_seeds_a_record_the_feed_cannot_take_away() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _capture, _home) = hermetic_probe("nameseed");
let mut world = booted_world();
log_in_as(&mut world, "Nelprifour", 0x2A);
assert_eq!(
probe_saw(&world).as_deref(),
Some("Nelprifour"),
"addon file scope reads the live character, as it always has (1230)"
);
let mut script = world
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
.expect("a VM");
// The feed's 2260 push: the descriptor landed, the name cache missed for our own guid.
script.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
has_object: true,
name: None,
..Default::default()
}),
);
assert_eq!(
script
.eval::<Option<String>>(r#"return UnitName("player")"#)
.unwrap()
.as_deref(),
Some("Nelprifour"),
"the record answers, so a nameless snapshot is invisible to the verb"
);
assert_eq!(
script
.eval::<Option<String>>(r#"local _, t = UnitClass("player"); return t"#)
.unwrap()
.as_deref(),
Some("WARRIOR"),
"…and the same for the other three fields the reference reads off that record (2263)"
);
// …and so does the logout despawn, which removes the token altogether.
script.set_unit("player", None);
assert_eq!(
script
.eval::<Option<String>>(r#"return UnitName("player")"#)
.unwrap()
.as_deref(),
Some("Nelprifour")
);
let _ = std::fs::remove_dir_all(&tmp);
}