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:
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user