idle sits you down and camps you, and the addons anchor, parse and draw

- five idle minutes sit you down and flag you AFK, thirty log you out through the ordinary countdown; holding a key is idle, moving the mouse is not, and a swimmer stays afloat (2092)
- a knockdown no longer wedges: the landing froze the clip that held the base-animation lock, leaving a knocked-down player on their back for the session (2098); the perf cost pill starts hidden (2099)
- Lua is exactly 1.12's 5.0 — no vararg expression, no #, no %, the remembered table size — so Cartographer loads without errors and Mik's Scrolling Battle Text shows its text, in its own shipped font now that addon TTFs load (2101, 2102, 2103)
- a widget name is a Lua global for SetPoint and its kin, so Bartender2's bars spread out; a button's NormalText disowns its justify, the raise renumbers hidden frames too, and a missing toc file warns instead of erroring (2105, 2100, 2104, 2107, 2106)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:35:03 +07:00
co-authored by Claude Fable 5.1
parent 1783660496
commit 99b5600e25
52 changed files with 3713 additions and 359 deletions
+12 -9
View File
@@ -46,16 +46,19 @@ sha1 = "0.10.6"
# section stood empty for the project's whole life.
#
# The one entry below is not a fork of a WoW crate at all. `lua-src` compiles the Lua the client's
# addon VM runs on, and the 1.12 corpus needs one opcode that Lua 5.1 deleted — the iterator-less
# generic-for (`for k, v in someTable do`), which 183 of 218 corpus addons are reached by and which
# is the first session-start error for 60 of them. It is an OPCODE, so no layer above the VM can
# reach it: not a library function, not a global, not a metamethod.
# addon VM runs on, and benilla's Lua has to accept EXACTLY the grammar the 1.12.1 client's Lua
# accepts — no more and no less. Six hunks, all byte-read out of the client's own parser and VM:
# three restore what 5.1 removed (the iterator-less generic-for `for k, v in someTable do`, which
# 183 of 218 corpus addons are reached by; nesting long strings; the constructor's compat
# semicolon), three delete what 5.1 added (`...` as an expression, `#`, `%`). Both directions are
# below the reach of any layer above the VM — an opcode and a grammar are not a library function,
# a global, or a metamethod — and the corpus ASKS about the second direction directly: 170
# `loadstring` sites compile a vararg expression purely to find out which interpreter they are on.
#
# The fork is currently BYTE-IDENTICAL to upstream's 5.1.5 sources, and is landed that way on
# purpose: the pipeline is proven transparent before the behavioural payload goes into it, so that
# when the suite moves, the patch is the only thing that could have moved it. The payload waits on
# an RE answer about what the real client's own Lua does — stock Lua 5.0 is corroboration, not the
# authority. `third_party/lua-src/BENILLA.md` has the current state and the verifying command.
# The fork landed BYTE-IDENTICAL to upstream's 5.1.5 sources on purpose: the pipeline was proven
# transparent before any behavioural payload went into it, so when the suite moved, the patch was
# the only thing that could have moved it. `third_party/lua-src/BENILLA.md` has the current state,
# the per-hunk byte evidence and the verifying command (decisions 1215, 1315, 2101).
[patch.crates-io]
lua-src = { path = "third_party/lua-src" }
# kira 0.12.1 with one patch: spatial gains and the volume tween evaluated per CHUNK and
+189 -24
View File
@@ -507,9 +507,13 @@ fn survey_one(
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
script.set_screen_size(1024.0, 768.0);
// Before anything runs: the AddOn API must answer for the whole installed set, not for nothing.
// `None` roots because a survey must never read or write the director's real saved variables
// (the same reason `drive_session_start` is not `finish_ui_load` — 1213 §4).
script.register_addons(registry.to_vec(), None, None, None);
// The **saved-variable** roots are `None` — a survey must never read or write the director's
// real ones (the same reason `drive_session_start` is not `finish_ui_load`, 1213 §4). The
// ADDONS root is not one of those and is passed in full (decision 2102): without it every
// `LoadAddOn("<a folder addon>")` answered `MISSING`, which is a state the real client cannot
// produce and is exactly the 1193/1212 fault one level up — `/msbt`'s
// `UIParentLoadAddOn("MikScrollingBattleTextOptions")` is the shape it hides.
script.register_addons(registry.to_vec(), Some(root.to_path_buf()), None, None);
seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
// The addon's DEPENDENCIES, first and recursively — `AddOn_Load 0x51f240`'s own first two
@@ -547,12 +551,20 @@ fn survey_one(
let baseline = RenderBaseline::of(&script);
let (errors, absent) = load_addon_files(&script, root, name, &toc);
// The registry has to agree with the VM about what has loaded: the live walk stamps each
// addon at `0x51f5ad`'s position (`ui_script::addons`'s `mark_addon_loaded`, just before
// `ADDON_LOADED`), and without it here `IsAddOnLoaded` answered nil for an addon whose files
// had just run — and a LoadOnDemand dependent of it got `DEP_NOT_DEMAND_LOADED` rather than
// loading (decision 2102). The dependency chain is stamped too, in load order.
for loaded in dep_order.iter().chain(std::iter::once(&name.to_string())) {
script.mark_addon_loaded(loaded);
}
let wants = missing_calls(root, name, &toc, &known, &dep_methods);
// AFTER the addon's files: a template it declares in its OWN XML is registered by then, so it
// is not missing. The check asks the VM's live registry, not a name list.
let missing_templates = missing_templates(&script, root, name, &toc);
let missing_inherits = missing_inherits(&script, root, name, &toc);
let session_errors = drive_session_start(&mut script, name, &dep_order);
let session_errors = drive_session_start(&mut script, name, &dep_order, installed);
let probe_errors = drive_ui_probe(&mut script);
// AFTER the UI probe and BEFORE the method oracle, and both halves of that are load-bearing.
// After, because the probe leaves the addon fully driven — and this pass re-OPENS what the
@@ -886,7 +898,12 @@ fn only_on(have: &[String], probes: &[String]) -> String {
/// second reaches the common `ScheduleEvent(..., 0)`/`(..., 0.05)` shapes and Ace's own one-second
/// `AceEvent_FullyInitialized` timer; it does not reach a ten-second self-heal, and that
/// under-report is stated rather than hidden.
fn drive_session_start(script: &mut UiScript, name: &str, deps: &[String]) -> Vec<String> {
fn drive_session_start(
script: &mut UiScript,
name: &str,
deps: &[String],
installed: &BTreeSet<String>,
) -> Vec<String> {
// **Every addon in the VM gets its OWN `ADDON_LOADED`, in load order** — the client fires one
// per loaded addon with that addon's folder in `arg1`, and a dependency's initialiser is
// almost always gated on exactly that:
@@ -938,34 +955,91 @@ fn drive_session_start(script: &mut UiScript, name: &str, deps: &[String]) -> Ve
let raised = script.errors().split_off(before);
raised
.into_iter()
.filter(|e| !raised_inside_a_dependencys_own_file(e, name, deps))
.filter(|e| !raised_inside_another_addons_own_file(e, name, installed))
.collect()
}
/// Does this raise belong to a DEPENDENCY's file rather than the surveyed addon's?
/// Does this raise belong to **another installed addon's** file rather than the surveyed addon's?
///
/// The rule `load_dependencies` already states for load errors, now enforceable for session errors
/// too: *a library that fails is its own row; blaming its consumers would count one fault once per
/// addon that embeds it.* Only the FIRST line is consulted — that is the raise site; the frames
/// below it are the call path, and a consumer calling into a library that then raises is still the
/// library's fault, exactly as it is at load time.
/// The rule `load_dependencies` already states for load errors, enforceable for session errors
/// since 1226: *a library that fails is its own row; blaming its consumers would count one fault
/// once per addon that embeds it.* Only the FIRST line is consulted — that is the raise site; the
/// frames below it are the call path, and a consumer calling into a library that then raises is
/// still the library's fault, exactly as it is at load time.
///
/// **Conservative on purpose.** A chunk that names no addon folder at all — our own FrameXML, an
/// `[string "Frame:OnEvent"]` handler — is KEPT, because the surveyed addon is what drove it. Only
/// a chunk that positively names one of this addon's own dependencies is dropped. Getting that
/// backwards would swing the column the other way, and the whole point of 1226 is that a proxy
/// which errs silently in one direction is how the number drifted in the first place.
fn raised_inside_a_dependencys_own_file(err: &str, name: &str, deps: &[String]) -> bool {
let Some(first) = err.lines().next() else {
/// **The exemption was "a DECLARED DEPENDENCY's file" and that was too narrow** (decision 2107).
/// Once the harness's VMs got a real AddOns root, `LoadAddOn` began doing what it does in a real
/// session, and the corpus's largest family drives it deliberately: `FuBar.lua:1034`'s
/// `LoadLoadOnDemandPlugins` demand-loads **every** installed `FuBar_*` plugin the moment any one
/// of them seats. So surveying `FuBar_MoneyFu` runs fifty *siblings*' file scope, and
/// `FuBar_BattlegroundFu`'s embedded `Glory-2.0` raises `Glory-2.0 requires Deformat-2.0` —
/// a library `FuBar_BattlegroundFu` does not ship and that a dozen of its siblings do, which is
/// this module's own stated one-VM-per-addon limitation and not a gap of ours. It is not
/// `FuBar_MoneyFu`'s row either: it is `FuBar_BattlegroundFu`'s, where the survey already counts it
/// once. **49 of the corpus's 219 addons were failing on somebody else's file.**
///
/// A demand-loaded sibling is the same category as a declared dependency — code the surveyed addon
/// did not write — so the test is now "an installed addon that is not this one", which subsumes
/// `deps` (every dependency is installed) and adds the siblings.
///
/// **Conservative on purpose, unchanged.** A chunk that names no addon folder at all — our own
/// FrameXML, an `[string "Frame:OnEvent"]` handler — is KEPT, because the surveyed addon is what
/// drove it. And the surveyed addon's own folder is tested FIRST, so a raise inside its own
/// `Libs\Ace\…` is never handed to an installed addon that happens to be called `Ace`.
fn raised_inside_another_addons_own_file(
err: &str,
name: &str,
installed: &BTreeSet<String>,
) -> bool {
let Some(first) = err.lines().next().map(str::to_ascii_lowercase) else {
return false;
};
// The surveyed addon's own folder always wins, even when a dependency's name is a substring of
// a path inside it.
if first.contains(&format!("\\{name}\\")) || first.starts_with(&format!("{name}\\")) {
let owns = |line: &str, folder: &str| {
line.contains(&format!("\\{folder}\\"))
|| line.contains(&format!("{folder}/"))
|| line.starts_with(&format!("{folder}\\"))
};
let mine = name.to_ascii_lowercase();
// The surveyed addon's own folder always wins, even when another addon's name is a substring
// of a path inside it.
if owns(&first, &mine) {
return false;
}
deps.iter()
.any(|d| first.contains(&format!("\\{d}\\")) || first.starts_with(&format!("{d}\\")))
if installed.iter().any(|d| owns(&first, d)) {
return true;
}
raised_inside_a_demand_load(err, &mine)
}
/// The raise site names **no file at all** — did it happen inside a `LoadAddOn` this addon made?
///
/// An inline `<OnEvent>`/`<OnLoad>` body is compiled under the FRAME's name
/// (`[string "AuctioneerFrame:OnEvent"]`), not the file's, so the folder test above cannot see
/// whose code it is — and 1226's conservative half then KEEPS it, charged to whoever drove it.
/// That is right for a handler the surveyed addon's own events reached, and wrong for one reached
/// through a demand load, which is somebody else's addon loading its own frames.
///
/// The traceback says which. `Stubby.lua:581`'s `inspectAddOn` calls `LoadAddOn` on every addon it
/// finds, Auctioneer's `AuctioneerFrame:OnEvent` raises inside that call, and the frame between
/// them is `[C]: in function 'LoadAddOn'`. So: walk the traceback from the raise site down, and if
/// a `LoadAddOn` frame comes **before** any frame naming the surveyed addon's own folder, the code
/// that raised belongs to the addon being loaded. It cost six addons their row — Stubby and the
/// five that pull it in — for one raise in Auctioneer, which has its own row already.
///
/// **The false negative, stated:** the surveyed addon's OWN inline handler, fired by an event that
/// happened to be dispatched inside a demand load it made, is exempted too. That is the same
/// direction the dependency exemption has always erred in, and the alternative — charging every
/// addon that calls `LoadAddOn` for its neighbours' code — is what this replaces.
fn raised_inside_a_demand_load(err: &str, mine: &str) -> bool {
for line in err.lines().skip(1).map(str::to_ascii_lowercase) {
if line.contains(&format!("\\{mine}\\")) || line.contains(&format!("{mine}/")) {
return false; // our own frame is above the boundary: our raise
}
if line.contains("in function 'loadaddon'") {
return true;
}
}
false
}
/// Invoke the reference's own UI entry points, so an addon's OVERRIDES actually execute.
@@ -2914,6 +2988,97 @@ mod tests {
);
}
/// **A raise in a DEMAND-LOADED SIBLING is that sibling's row, not the surveyed addon's**
/// (decision 2107) — the FuBar shape, verbatim from the corpus.
///
/// `FuBar.lua:1034`'s `LoadLoadOnDemandPlugins` demand-loads every installed `FuBar_*` the
/// moment any one of them seats, so surveying `FuBar_MoneyFu` runs `FuBar_BattlegroundFu`'s
/// file scope. Its embedded `Glory-2.0` wants a `Deformat-2.0` a dozen of its siblings ship and
/// it does not — this module's one-VM-per-addon limitation, and `FuBar_BattlegroundFu`'s own
/// row, where the survey counts it once. It cost 49 addons their session-start row.
#[test]
fn a_raise_in_another_installed_addons_file_is_that_addons_row() {
let installed: BTreeSet<String> = ["fubar", "fubar_moneyfu", "fubar_battlegroundfu", "ace"]
.into_iter()
.map(str::to_string)
.collect();
let sibling = "LoadAddOn: FuBar_BattlegroundFu/lib\\Glory-2.0\\Glory-2.0.lua: runtime \
error: ...Ons\\FuBar_BattlegroundFu\\lib\\Glory-2.0\\Glory-2.0.lua:23: \
Glory-2.0 requires Deformat-2.0";
assert!(
raised_inside_another_addons_own_file(sibling, "FuBar_MoneyFu", &installed),
"a sibling's own file is the sibling's row"
);
// The surveyed addon's OWN file is always its own row — including a library it embeds
// whose folder name is itself an installed addon.
let mine = "runtime error: Interface\\AddOns\\FuBar_MoneyFu\\Libs\\Ace\\Ace.lua:8: boom";
assert!(!raised_inside_another_addons_own_file(
mine,
"FuBar_MoneyFu",
&installed
));
// A chunk naming no addon folder at all is KEPT — the surveyed addon drove it.
let ours = "runtime error: [string \"Frame:OnEvent\"]:2: attempt to index a nil value";
assert!(!raised_inside_another_addons_own_file(
ours,
"FuBar_MoneyFu",
&installed
));
// A folder that is not installed is not somebody else's row either.
let stranger = "runtime error: Interface\\AddOns\\NotInstalled\\x.lua:1: boom";
assert!(!raised_inside_another_addons_own_file(
stranger,
"FuBar_MoneyFu",
&installed
));
}
/// **A raise inside a `LoadAddOn` the surveyed addon made is the LOADED addon's row** — even
/// when the raise site names no file at all (decision 2107).
///
/// An inline `<OnEvent>` body is compiled under the frame's name, so the folder test is blind
/// to it. `Stubby.lua:581` demand-loads every addon it finds; Auctioneer's
/// `AuctioneerFrame:OnEvent` raises inside that call. Six addons — Stubby and the five that
/// pull it in — were failing on that one raise, which Auctioneer's own row already carries.
#[test]
fn a_raise_inside_a_demand_load_belongs_to_the_addon_being_loaded() {
let installed: BTreeSet<String> = ["stubby", "auctioneer", "enchantrix"]
.into_iter()
.map(str::to_string)
.collect();
let through_loadaddon =
"runtime error: [string \"AuctioneerFrame:OnEvent\"]:4: attempt to \
index field 'Core' (a nil value)\n\
stack traceback:\n\
\t[C]: in ?\n\
\t[string \"AuctioneerFrame:OnEvent\"]:4: in function <...>\n\
\t[C]: in function 'LoadAddOn'\n\
\tInterface\\AddOns\\Stubby\\Stubby.lua:581: in upvalue 'inspectAddOn'";
assert!(
raised_inside_another_addons_own_file(through_loadaddon, "Stubby", &installed),
"the LoadAddOn frame sits between the raise and Stubby's own code"
);
assert!(
raised_inside_another_addons_own_file(through_loadaddon, "Enchantrix", &installed),
"…and the same for an addon that only pulled Stubby in"
);
// The surveyed addon's OWN frame ABOVE the boundary keeps the raise: it drove it itself.
let ours_first = "runtime error: [string \"StubbyFrame:OnEvent\"]:2: boom\n\
stack traceback:\n\
\tInterface\\AddOns\\Stubby\\Stubby.lua:12: in function 'Stubby.Thing'\n\
\t[C]: in function 'LoadAddOn'";
assert!(!raised_inside_another_addons_own_file(
ours_first, "Stubby", &installed
));
// No LoadAddOn anywhere: 1226's conservative KEEP is untouched.
let plain = "runtime error: [string \"StubbyFrame:OnEvent\"]:2: boom\n\
stack traceback:\n\
\t[C]: in ?";
assert!(!raised_inside_another_addons_own_file(
plain, "Stubby", &installed
));
}
/// mlua's `[string "Frame:OnLoad"]:2:` chunk name is a position, not words.
#[test]
fn a_chunk_name_position_is_not_mistaken_for_the_message() {
+12 -4
View File
@@ -89,9 +89,11 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome>
};
script.set_instruction_budget(super::ADDON_INSTRUCTION_BUDGET);
script.set_screen_size(1024.0, 768.0);
// `None` roots: a probe must never read or write the director's real saved variables, for the
// same reason the survey does not call `finish_ui_load` (1213 §4).
script.register_addons(registry, None, None, None);
// `None` **saved-variable** roots: a probe must never read or write the director's real ones,
// for the same reason the survey does not call `finish_ui_load` (1213 §4). The AddOns root is
// passed, exactly as [`super::survey_one`] passes it — a probe VM that answers `MISSING` to
// every `LoadAddOn` is not the VM the row came from (decision 2102).
script.register_addons(registry, Some(root.to_path_buf()), None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
@@ -106,7 +108,13 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome>
);
let (load_errors, _absent) = super::load_addon_files(&script, root, name, &toc);
let session_errors = super::drive_session_start(&mut script, name, &dep_order);
// Same stamp the survey applies, for the same reason: the registry must agree with the VM
// about what is loaded, or `IsAddOnLoaded` and every dependency verdict answer for a session
// that does not exist.
for loaded in dep_order.iter().chain(std::iter::once(&name.to_string())) {
script.mark_addon_loaded(loaded);
}
let session_errors = super::drive_session_start(&mut script, name, &dep_order, &installed);
let answers = evals
.iter()
+2 -1
View File
@@ -890,7 +890,8 @@ fn pin_scene(
roots: Query<&Transform, Without<WorldCamera>>,
mut cam: Query<&mut Transform, With<WorldCamera>>,
) {
perf.visible = false; // the perf HUD is default-on; suppress it for a pristine, UI-free shot
perf.visible = false; // hidden by default since 2099, but a session may have chorded it up —
// a capture is pristine and UI-free whatever the run did
// A glue screen has no world to light, no clock to pin and no camera to place. The shutter
// above needs none of that — it is watching the framebuffer.
let Some(scenario) = ctx.scenario else {
@@ -191,6 +191,10 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
"F" => KeyCode::KeyF,
"Ctrl" => KeyCode::ControlLeft,
"Shift" => KeyCode::ShiftLeft,
// The cost pill's toggle, dev chord + `P` (`perf::hud::toggle_hud`). Reachable no other
// way — it is a host chord, not a binding or a Lua verb — and since the pill starts hidden
// (2099) a probe that wants to see it in a shot has to press it.
"P" => KeyCode::KeyP,
// The text-editing keys. Added for decision 1077's hyperlink-atomicity law, whose whole
// observable — "one BACKSPACE removes a whole item link" — is a keypress no chat command
// and no Lua chunk can reach (`EditBox` has no Lua deletion API; the law lives behind the
+17 -3
View File
@@ -48,7 +48,7 @@ mod tests;
mod wound;
pub(super) use grip::drive_hand_grip;
use play::{oneshot_finished, play_clip, roll_loop, roll_oneshot};
use play::{holds_own_clip, oneshot_finished, play_clip, roll_loop, roll_oneshot};
use wound::{wound_evict, wound_trigger, wound_upkeep, WoundEdge};
/// The weight of a masked upper-body one-shot overlay ([`AnimDriver::overlay`]) over the base clip on
@@ -1126,9 +1126,14 @@ pub(super) fn drive_animations(
// ref's mid-air cast). Cutting an airborne clip freezes the outgoing node first —
// the pose-snapshot decay of the client's op4 blend, scoped exactly like
// [`leave_special`]'s (decision 0503).
if matches!(special, Some(select::Special::Jump | select::Special::Fall)) {
if let Some(sp) =
special.filter(|sp| matches!(sp, select::Special::Jump | select::Special::Fall))
{
// …the ARC's clip, not merely whatever bone 0 holds ([`holds_own_clip`]) —
// the freeze's missing predicate, at its second site (decision 2098).
if let Some(active) = tr
.get_main_animation()
.filter(|&n| holds_own_clip(anims, catalog, sp, n))
.and_then(|n| player.animation_mut(n))
{
active.set_speed(0.0);
@@ -1448,7 +1453,16 @@ pub(super) fn drive_animations(
let masked_played = masked_played || hold_played.is_some();
let base_played = base_played || (drv.mode, drv.gait) != pre_state;
// A mode/gait change is a **proxy** for the mode machine's plays, and the base-anim lock
// is the one state where the proxy is wrong for longer than the single frame its own doc
// allows: while the lock refuses, the machine still walks its brackets — Gait →
// Entering(Jump) → Looping(Jump) → Land — with every play declined, so a change there
// means nothing was armed at all. Claiming otherwise evicted the victim's flinch off the
// key-bone and latched the weapon trail's edge (2076) on a play that never happened
// (decision 2098). Only the PROXY is gated: an arm that reached the player directly says
// so on its own.
let base_played =
base_played || ((drv.mode, drv.gait) != pre_state && !drv.base_lock.refuses());
// The weapon-trail latch's edge (decision 2076) — `0x5fe2f0` is the image's single
// animation entry point, so ANY start consumes the arm, the mode machine's gait plays
// included. Written every pass (never OR'd) so it is exactly this frame's.
@@ -251,6 +251,15 @@ pub(super) fn play(
rng: &mut u32,
window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>,
) {
// The lock's guard is `PlayAnimation`'s **front door**, above the arm helper that draws the
// rolls (`0x5fe2f0`'s epilogue return is at `0x5fe3a1`; the `_rand()` sites live inside
// `0x5fdba0`'s op4 call). So a refused play must not roll either: the variation and replay
// draws come off the ONE shared stream (decision 0114), and rolling them for a body that
// cannot move would perturb every other unit's picks for the clip's whole 2 s — the same
// churn 2096 caught in the gait selector's live trace, at the event-play door instead.
if lock.refuses() {
return;
}
if let Some(c) = find_resolved(anims, id, catalog) {
let (c, repeat) = if looping {
let (c, r) = roll_loop(anims, c, relaxed, rng);
@@ -342,6 +351,33 @@ pub(super) fn oneshot_finished(
}
}
/// Whether bone 0 still holds `sp`'s **own** clip — the arc's enter or its loop, resolved through
/// the model's baked fallback exactly as [`play`] resolved it when it armed (decision 0082) and
/// compared on the *resolved* id, so a rolled variation counts as the same clip.
///
/// This is the predicate the airborne snapshot-freeze (decision 0503, scoped by 1566) never had.
/// The freeze exists to still **the cut airborne clip** before the landing cross-fades over it;
/// both its sites took whatever bone 0 happened to hold, on the unstated assumption that the arc's
/// own clip is what is there. The base-anim lock (2096) is the first thing that ever falsified it,
/// and it falsified it catastrophically: every play the arc asked for was refused, so bone 0 still
/// held the `Knockdown` that took the lock — and the landing stopped **that** dead. A clip stopped
/// dead never finishes, and the lock clears on the finished id, so the body stayed on its back for
/// the rest of the session with every later play refused (the director's report, decision 2098).
pub(super) fn holds_own_clip(
anims: &ModelAnimations,
catalog: Option<&AnimDataCatalog>,
sp: Special,
node: bevy::animation::graph::AnimationNodeIndex,
) -> bool {
let Some(cur) = anims.clips.iter().find(|c| c.node == node) else {
return false;
};
[sp.enter(), sp.loop_id()]
.into_iter()
.filter_map(|id| find_resolved(anims, id, catalog))
.any(|head| head.anim_id == cur.anim_id)
}
/// Enter the Special `sp`, returning the mode to adopt. A pose or a jump plays its enter one-shot
/// and settles through [`Mode::Entering`]; **Fall has no enter** — the client plays the Fall(40)
/// loop directly the tick FALLINGFAR latches (`0x602c40`) — so it goes straight to
@@ -430,11 +466,32 @@ pub(super) fn leave_special(
// derivation; what produces the reference's lingering kick is not yet known.
// …and the frozen node is NAMED (decision 0906), so the per-frame rate write
// ([`sync_base_rate`]) skips it instead of restarting the clock a line above just stopped.
//
// …and it is **the arc's own clip** that is stilled, never merely whatever bone 0 holds
// ([`holds_own_clip`]) — the predicate this always meant and never wrote down.
if matches!(sp, Special::Jump | Special::Fall) {
if let Some(node) = tr.get_main_animation() {
if let Some(active) = player.animation_mut(node) {
active.set_speed(0.0);
*frozen = Some(node);
if holds_own_clip(anims, catalog, sp, node) {
if let Some(active) = player.animation_mut(node) {
active.set_speed(0.0);
*frozen = Some(node);
}
} else if benilla_assets::trace::enabled() {
// The declined freeze is TRACED, because the wedge it used to cause was
// completely silent: nothing logs a clip being stopped, and a stopped clip that
// holds the base-anim lock ends the unit's animation for the session (2098).
let held = anims
.clips
.iter()
.find(|c| c.node == node)
.map(|c| c.anim_id);
benilla_assets::trace::line(
"anim",
&format!(
"anim freeze DECLINED leaving {sp:?}: bone 0 holds {held:?}, not this \
arc's clip"
),
);
}
}
}
@@ -4321,4 +4321,108 @@ mod base_anim_lock {
"already playing it — the leg leaves the block having done nothing"
);
}
/// The airborne clips, on the same victim — the director's follow-up report: *"there is some
/// bug it seems, I ended up stuck laying down after a knock, might be because I was jumping
/// or running at the same time"*.
fn airborne_model() -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, STAND_NODE, true), // Stand
clip(121, KNOCKDOWN_NODE, false), // Knockdown — takes the lock
clip(37, 3, false), // JumpStart
clip(38, 4, true), // Jump hang
clip(39, 5, false), // JumpEnd
clip(40, 6, true), // Fall
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
}
}
fn knocked_jumper(app: &mut App) -> Entity {
app.world_mut()
.spawn((
airborne_model(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
MovementState::default(),
))
.id()
}
/// **The wedge**: jumping while the lock is held used to stop the locked clip DEAD, and a clip
/// that never finishes never releases the lock — so the body lay on its back for the rest of
/// the session, every subsequent play refused.
///
/// The mechanism is 0503's snapshot-freeze in [`super::play::leave_special`], which stills the
/// cut airborne clip before the landing cross-fades over it. It took whatever bone 0 held, on
/// the assumption that the arc's own clip is what it armed — and the lock is the first thing
/// ever to falsify that: every play the arc asked for was refused, so bone 0 still held the
/// `Knockdown`, and the landing froze *that*.
#[test]
fn a_jump_taken_while_locked_never_freezes_the_locked_clip() {
let mut app = app();
let unit = knocked_jumper(&mut app);
app.update(); // settle: Stand
oneshot(&mut app, unit, 121);
let knockdown = AnimationNodeIndex::new(KNOCKDOWN_NODE as usize);
assert_eq!(
main_node(&app, unit),
Some(knockdown),
"the impact kit's Knockdown holds the base"
);
// Space, flat on their back: the launch enters the bracket, and every clip in the bracket
// is refused — so bone 0 still holds the Knockdown for the whole arc.
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FALLING,
vertical_speed: 7.96,
..Default::default()
});
advance(&mut app, 16);
assert!(
!app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.started_anim,
"the bracket walked with its play declined — nothing started, so nothing downstream may read a play out of the mode change (the flinch's eviction, 2076's trail edge)"
);
advance(&mut app, 16);
assert_eq!(
main_node(&app, unit),
Some(knockdown),
"the arc's own JumpStart/hang are refused, exactly as the guard orders"
);
// Touchdown.
app.world_mut()
.entity_mut(unit)
.insert(MovementState::default());
advance(&mut app, 16);
assert_eq!(
app.world().entity(unit).get::<AnimDriver>().unwrap().frozen,
None,
"the landing's freeze is the AIRBORNE clip's alone — it must never still a clip the \
arc did not arm"
);
assert_eq!(
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(knockdown)
.map(bevy::animation::ActiveAnimation::speed),
Some(1.0),
"…so the Knockdown runs on, finishes, and releases the lock"
);
}
}
+15 -11
View File
@@ -58,17 +58,21 @@ const PILL_YIELD_GAP: f32 = 4.0;
/// quads are reused byte-identical (see [`pill_quads`]).
const HUD_REFRESH_SECS: f32 = 0.25;
/// HUD state. The **dev chord + `P`** toggles `visible` (default on — it's a standing dev
/// surface). `visible` is `pub(crate)` so the capture harness ([`crate::capture`]) can force the
/// overlay off for pristine, UI-free screenshots.
/// HUD state. The **dev chord + `P`** (Ctrl+Shift+P) toggles `visible`, and it starts **hidden**
/// — the director's call, 2026-09-08: the pill sits over the game for the whole session and the
/// game is what a dev build is for looking at. It is an instrument you reach for, not furniture.
/// `visible` is `pub(crate)` so the capture harness ([`crate::capture`]) can force the overlay off
/// for pristine, UI-free screenshots.
///
/// **`WOW_PERF_HUD=0` starts it hidden**, which is how the HUD gets priced. 1370 records the open
/// gap: every campaign anchor is measured on a binary that is drawing this overlay, at a cost
/// booked as "est 0.4–1.2 ms CPU + unquantified GPU" — an estimate, never a measurement, because
/// nothing could turn the fixture off without also changing the binary. One env var makes it an
/// interleaved A/B on *one* binary instead (`scripts/leg.sh`), so the constant baked into every
/// anchor becomes a number. The meters keep sampling either way: only the drawing stops, which is
/// the half being priced.
/// **`WOW_PERF_HUD=1` starts it shown**, which is how the HUD gets priced — the knob kept its
/// meaning in both spellings when the default flipped, so `0` and unset are both hidden. 1370
/// records the open gap: every campaign anchor was measured on a binary that draws this overlay,
/// at a cost booked as "est 0.4–1.2 ms CPU + unquantified GPU" — an estimate, never a measurement,
/// because nothing could turn the fixture off without also changing the binary. One env var makes
/// it an interleaved A/B on *one* binary instead (`scripts/leg.sh`), so the constant baked into
/// every anchor becomes a number. The meters keep sampling either way: only the drawing stops,
/// which is the half being priced — and with the default flipped, the *unmeasured* leg is now the
/// one nobody is running.
#[derive(Resource)]
pub(crate) struct PerfHud {
pub(crate) visible: bool,
@@ -88,7 +92,7 @@ pub(crate) struct PerfHud {
impl Default for PerfHud {
fn default() -> Self {
Self {
visible: std::env::var("WOW_PERF_HUD").as_deref() != Ok("0"),
visible: std::env::var("WOW_PERF_HUD").as_deref() == Ok("1"),
snap: FrameStats::default(),
// −∞, so the very first frame refreshes rather than drawing an empty snapshot.
snap_at: f32::NEG_INFINITY,
+9
View File
@@ -198,6 +198,15 @@ pub(crate) fn self_controlled(unit_flags: u32) -> bool {
/// takes a GUID fast path and lands on the identical word.
pub(crate) const UNIT_FLAG_IN_COMBAT: u32 = 0x0008_0000;
/// `UNIT_FLAG_TAXI_FLIGHT` — the same `UNIT_FIELD_FLAGS` word, **bit 20** (vmangos
/// `UnitDefines.h:511`; the client reads it as `shr reg,0x14; test rl,1` — wow-re counted 20
/// independent sites of that idiom with no shared gate, `unit-flags-movement-gates.md` §4).
///
/// Beside its neighbour for the same reason that one is here: two readers today that have nothing
/// to do with each other — `SetStandState`'s own guard #3 and the idle handler's auto-AFK gate —
/// and a bit that is spelled out twice is a bit that eventually drifts.
pub(crate) const UNIT_FLAG_TAXI_FLIGHT: u32 = 0x0010_0000;
/// Ask for a **stand state** — the client's `SetStandState(newState)` (`0x5ed430`: send
/// `CMSG_STANDSTATECHANGE` + apply locally through `0x6127b0`), as a message so every path that
/// wants a posture funnels through the ONE setter in [`posture`] (the [`crate::creature_anim::
+16 -2
View File
@@ -14,9 +14,11 @@ use crate::ui_script::UiInput;
#[cfg(test)]
mod ace_gate_tests;
/// `/afk` and `/dnd` — the two commands' asymmetric law, the optimistic AFK mirror `[0xb6e5cc]`,
/// and the implicit clear every other chat send and every movement press carries (2088).
mod away;
/// The world broadcasts (`SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`) — the
/// AreaTable/ServerMessages resolve and the joined-defense-channel walk they land on.
mod away;
mod broadcast;
mod channels;
/// The combat log's chat lines (B297) — classification, chat type, and the GlobalString key each
@@ -31,12 +33,15 @@ pub(crate) mod edit;
mod event;
mod feed;
mod frames;
/// The idle handler — the 5-minute auto-sit / auto-AFK and the 30-minute camp.
mod idle;
mod input;
/// The language gate — the exemptions and the fluency lookup behind the chat garble (B262).
mod language;
/// `LoggingChat`/`LoggingCombat` — the two log files `/chatlog` and `/combatlog` toggle.
mod logging;
/// The chat windows' saved look (B246, decision 1589) — where the tab menu's tint/alpha/font-size
/// picks are read from at login and written back at logout.
mod logging;
mod settings;
#[cfg(test)]
mod tests;
@@ -62,6 +67,7 @@ impl Plugin for UiChatPlugin {
app.init_resource::<ChatLog>()
.init_resource::<away::AfkMirror>()
.init_resource::<away::AfkMirrorMemo>()
.init_resource::<idle::LastInput>()
.init_resource::<frames::ChatWindows>()
.init_resource::<edit::ChannelState>()
.init_resource::<channels::ZoneChannelWalk>()
@@ -121,6 +127,10 @@ impl Plugin for UiChatPlugin {
// chat source.
.add_systems(Update, broadcast::feed_broadcasts.before(feed::feed_chat))
.add_systems(Update, feed::feed_chat.before(UiInput))
// The last-input stamp `[0xcf0bc8]`. Deliberately NOT in-world-gated and
// deliberately ahead of the UI pass: the reference stores it on the raw input bus,
// before dispatch, so a keystroke the chat box swallows still counts as input.
.add_systems(Update, idle::stamp_input.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))
@@ -140,6 +150,10 @@ impl Plugin for UiChatPlugin {
away::reconcile_afk_mirror,
// …and the four movement clears, which share the CVar and the mirror.
away::movement_clears_afk,
// The idle handler (2092) — one timer, three legs. After the clears, so a
// press that both stamps the clock and drops the flag is settled before the
// timer that would otherwise re-raise it is read.
idle::idle_handler,
input::drain_chat_input,
// An addon's own line into the wire (decision 1199). AFTER the box's drain
// and in the same chain, so a `SendChatMessage` fired from a slash handler
+15
View File
@@ -443,6 +443,21 @@ impl ChatLog {
.filter(|p| !matches!(p, Pending::Addon { .. }))
.count()
}
/// The text of every already-composed line waiting to render, in order — the queue's
/// [`Pending::Event`] entries, which is what everything that pushes a finished sentence
/// ([`Self::push_event`]) lands as. The `Wire`/`Notice`/`Roll` shapes are deliberately absent:
/// those have no text yet, and a caller asserting on a sentence wants only the ones that do.
#[cfg(test)]
pub(crate) fn pending_lines(&self) -> Vec<String> {
self.pending
.iter()
.filter_map(|p| match p {
Pending::Event(e) => Some(e.text.clone()),
_ => None,
})
.collect()
}
}
/// The VM's own string table as a lookup — `getglobal(key)`, which is where every sentence this
+779
View File
@@ -0,0 +1,779 @@
//! **The idle handler** — `WorldFrame::Render 0x482ea0`, the one reference function that watches
//! how long it has been since the player last touched anything and does three unrelated-looking
//! things about it (wow-re `object-layer/scratch/standstate-movement-trigger.md` §5.4 and
//! `ui/scratch/afk-dnd-command-law.md` §10/§12).
//!
//! One module because it is **one timer**, read once per frame against one global stamp, with a
//! three-way branch hung off it:
//!
//! | idle | what happens |
//! |---|---|
//! | `< 300 000 ms` | nothing at all — `0x482ed8 jl 0x482fbf` leaves the whole block |
//! | `300 000 – 1 799 999 ms` | **auto-sit** (`0x482f91 call 0x5ed430(1)`), then **auto-AFK** (`0x482fba call 0x5eb740(NULL)`) |
//! | `>= 1 800 000 ms` | print `IDLE_MESSAGE`, then `0x5ab000` — and pointedly **no** sit and **no** AFK |
//!
//! Both thresholds are read off the immediates: `0x482ecd lea ecx,[eax-0x493e0]` is `-300 000`, and
//! `0x482ede add eax,0xffe488c0` is `-1 800 000`.
//!
//! **The 30-minute leg is a CAMP, not a kick.** `0x5ab000` is `ClientServices::SendLogout(flag,
//! force)` (wow-re `system/net/ledger.tsv`) — the *same* function the game menu's Logout button
//! reaches through `Logout 0x489390`, sending `CMSG_LOGOUT_REQUEST 0x4B`. So the server decides:
//! it refuses outright in combat, logs you out instantly in an inn, and otherwise runs the
//! twenty-second countdown that [`crate::ui_logout`] already narrates as the CAMP dialog. A
//! re-implementation that tore the socket down here would be both unfaithful and much ruder than
//! the thing it was imitating.
//!
//! **Nothing here needs a latch of its own, because every leg is gated on the state it is about to
//! create.** The sit is refused once seated, the auto-AFK is gated on the mirror `[0xb6e5cc] == 0`
//! and sets it, and the logout request is swallowed by the dispatcher's own pending byte
//! `[session+0x1b1d]`. That self-limiting shape is the reference's, and it is why a handler that
//! runs every single frame for twenty-five minutes does not spam the wire.
//!
//! The third of those did not exist here until this landed. [`crate::ui_logout`] knew about
//! `[session+0x1b1d]` in prose and never modelled it, because the only caller was a button a human
//! presses once; a caller that asks sixty times a second is what made the gap load-bearing. It is
//! built where the reference has it — in the dispatcher, covering every caller — rather than as a
//! private flag here.
use std::time::Duration;
use bevy::input::keyboard::KeyboardInput;
use bevy::input::mouse::{AccumulatedMouseMotion, MouseButtonInput, MouseWheel};
use bevy::prelude::*;
use bevy::time::Real;
use super::away::{afk_line, push_system, AfkMirror};
use super::feed::ChatLog;
/// **`[0xcf0bc8]` — the last-input millisecond stamp**, the one thing the idle timer subtracts
/// from.
///
/// Elapsed real time, which is the same quantity both sides of the reference's subtraction come
/// from: the stamp is written by exactly one call (`0x42d7bb`, inside the ring writer `0x42d780`)
/// to the same `0x42c010` the handler reads at `0x482ebe`. `GetMessageTime` is not imported and
/// `MSG.time` is never read — the OS message's own clock never enters this.
///
/// **Process-global, zero-initialised, and never reset** — including at world enter, which is the
/// obvious-looking thing to do and is wrong. One cell serves the login screen, character select
/// and the world alike, and it starts at zero, so a client whose first five minutes are untouched
/// is idle by the reference's own reckoning. [`stamp_input`] is ungated for the same reason.
#[derive(Resource, Debug, Clone, Copy, Default)]
pub(crate) struct LastInput(Duration);
/// **5 minutes** — `0x482ecd lea ecx,[eax-0x493e0]`, `0x493e0` = 300 000 ms.
const AUTO_AFK_AFTER: Duration = Duration::from_millis(300_000);
/// **30 minutes** — `0x482ede add eax,0xffe488c0`, i.e. `eax - 1 800 000`.
///
/// **Absolute, not five-plus-thirty.** The subtraction above it writes `ecx`, so `eax` still holds
/// the raw elapsed when this one runs. Reading the pair as two chained deductions gives 35 minutes
/// and is the natural misreading of the two-instruction sequence; the §5 round caught itself
/// making it.
const AUTO_LOGOUT_AFTER: Duration = Duration::from_millis(1_800_000);
/// Stamp the clock — the **seven** writers of `[0xcf0bc8]`, all registered on the one input ring
/// at priority `1.0f` (`0x765c04`–`0x765cfe` via `0x41fca0`), all storing the same ring field
/// `payload[3]` (wow-re `ui/scratch/idle-timer-input-stamp-law.md`, a §5 with four independent
/// reproductions and a closed image-wide census of the dword: 37 occurrences, 7 W / 30 R).
///
/// | writer | bus category | what the player did |
/// |---|---|---|
/// | `0x765f34` | 8 | a key went **down** — first press only |
/// | `0x765fec` | 9 | a key came **up** |
/// | `0x7662fb` | 0xb | a mouse button went **down** |
/// | `0x766152` | 0xc | the mouse **moved** (absolute, filtered) |
/// | `0x766291` | 0xd | the mouse **moved** (delta) |
/// | `0x766461` | 0xe | a mouse button came **up** |
/// | `0x76651f` | 0x10 | the **wheel** turned |
///
/// **The keyboard and the mouse follow opposite rules, and there is no single sentence covering
/// both.** A held key stamps once; a moved mouse stamps continuously. What does *not* stamp:
///
/// - **Auto-repeat.** `0x424810` promotes category 8 to 0xa at `0x4248b3` when the key is already
/// held, and `0x766070` (category 0xa) has no store. So **holding a key is idle** — running with
/// W held, or on autorun, reaches the five-minute auto-AFK. This is the single most surprising
/// thing the round found and it is deliberate here, not an omission.
/// - Zero-motion moves and the pump's synthetic hover re-pick (`0x7660d0`'s `0x766122`–`0x766140`
/// filter), which is the bypass `hover-hide-and-tooltip-owner-law.md` recorded without pinning.
/// - Double-clicks, `WM_MOUSEHWHEEL`, `WM_CHAR`, the joystick, and any VK outside `0x42d800`'s
/// table.
/// - Mouse **movement and left-button-up during an OS window drag** — a title-bar drag or border
/// resize, mirrored from `SetCapture`/`ReleaseCapture` in `[0x884e64]`. We inherit that for free:
/// an OS-driven window drag delivers no motion to the app at all.
///
/// **Mouselook is NOT an exclusion** — it is `[0x884e5c]`, a different global entirely, and all it
/// picks is which arm enqueues (absolute → category 0xc, delta → category 0xd, both stamping). A
/// player turning the camera with the right button held is *not* idle. This module briefly recorded
/// the opposite, off a mislabelled global, before the second round pinned `[0x884e64]`'s writers to
/// `SetCapture`.
///
/// **The store sits on the raw input bus, ahead of dispatch**, so a keystroke a frame *consumes*
/// still stamps: typing into the chat edit box is input, and so is a click that only moves a
/// window. That is why this reads the raw messages rather than [`crate::bindings::BindingsState`],
/// which is the post-dispatch view and would let a player who is busy typing go AFK mid-sentence.
///
/// Runs **outside** the in-world gate the handler itself carries, because the stamp is
/// process-global: character select, the login screen and the world all write the one cell.
pub(crate) fn stamp_input(
time: Res<Time<Real>>,
mut last: ResMut<LastInput>,
mut keys: MessageReader<KeyboardInput>,
mut buttons: MessageReader<MouseButtonInput>,
mut wheel: MessageReader<MouseWheel>,
motion: Res<AccumulatedMouseMotion>,
) {
// `count()` rather than `next().is_some()`, and bitwise `|` rather than `||`: both are about
// draining. A reader that stops at the first message leaves the rest on its cursor and re-sees
// them next frame, and a short-circuited `||` never touches the later queues at all — either
// one keeps the clock warm for frames after the input actually stopped.
// `repeat` is category 0xa, which has no store — a held key stops counting after its first
// press. (`Released` never carries the flag, so this one filter covers both edges.)
let any = (keys.read().filter(|k| !k.repeat).count() != 0)
| (buttons.read().count() != 0)
| (wheel.read().count() != 0)
| (motion.delta != Vec2::ZERO);
if any {
last.0 = time.elapsed();
}
}
/// **What the timer decides this frame** — a value rather than three writes, for the reason
/// [`super::away::AwayOutcome`] is one: the whole content of `0x482ea0`'s block is which legs move
/// together and which are mutually exclusive, and that is exactly what a re-implementer gets wrong
/// by treating "idle" as a single state with three effects.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct IdleAction {
/// `0x482f91 call 0x5ed430(1)`.
pub(crate) sit: bool,
/// `0x482fba call 0x5eb740(NULL)`.
pub(crate) afk: bool,
/// `0x482f1a` + `0x5ab000` — `IDLE_MESSAGE` and the logout request.
pub(crate) camp: bool,
}
/// The state the two five-minute legs are gated on. Read off our own descriptor; each field is one
/// test in the reference's block, and no two of them share a gate.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct IdleGates {
/// `UNIT_FIELD_BYTES_1` byte 0. The sit wants `0`.
pub(crate) stand_state: u8,
/// `UNIT_FIELD_FLAGS` bit 19 (`0x80000`) — the sit's.
pub(crate) in_combat: bool,
/// `UNIT_FIELD_MOUNTDISPLAYID > 0` — the sit's.
pub(crate) mounted: bool,
/// The sit's fourth gate, `0x482f84 call 0x6103a0` / `jne` past the setter — which requires the
/// predicate **false**, and for a solo player reduces to `[0xc4d888] == 0xc`, its normal
/// in-world value.
///
/// **Exactly one thing benilla has puts it elsewhere.** The global's writer census is closed —
/// 25 dword occurrences, 4 writes, three of them the literal `0xc` — so every non-`0xc` value
/// comes through the one `StartMoveAction 0x611130`, whose eleven kinds are the click-to-move
/// family (4, 8), the move-to-interact family (5, 6, 7, 9, 0xa) and **`3` = `/follow`**. Mode 3
/// is co-extensive with follow being active, proven twice over: `0x489e00` is `FollowUnit`, and
/// `AUTOFOLLOW_BEGIN`/`AUTOFOLLOW_END` (367/368) have exactly one fire site each, the second
/// gated on `[0xc4d888] == 3`.
///
/// **Autorun never touches the global** — no writer lies in the movement-command band at all;
/// the only traffic runs the other way (`0x5146d6` *calls the cancel*). So this gate is not a
/// stand-in for "moving", and it is not redundant with `stand_state_refused`: a follow whose
/// target has stopped walking is motionless, passes every movement test, and is still the one
/// player the reference will not seat.
pub(crate) following: bool,
/// `UNIT_FIELD_FLAGS` bit 20 (`0x100000`) — the **AFK's**, not the sit's.
pub(crate) on_taxi: bool,
/// The optimistic mirror `[0xb6e5cc]` — the AFK's second gate, and the anti-repeat.
pub(crate) afk: bool,
}
/// Resolve the timer against the gates — `0x482ea0`'s block, byte for byte.
///
/// **The two bands are exclusive, and that is the easy thing to get wrong.** `0x482ee5 jl
/// 0x482f39` is what jumps *into* the 5–30 minute block, so reaching thirty minutes takes the
/// other arm entirely: the camp leg neither sits you nor marks you AFK. A client that let the
/// thirty-minute case fall through the five-minute one would sit a player down on the same frame
/// it asked to log them out.
///
/// **Nothing latches.** Every leg is gated on the state it is about to create — the sit on
/// `stand_state == 0`, the AFK on the mirror it sets, and the camp on the logout request's own
/// pending latch downstream — so a handler that runs every frame for twenty-five minutes emits
/// exactly one of each.
pub(crate) fn idle_action(idle: Duration, gates: IdleGates) -> IdleAction {
if idle < AUTO_AFK_AFTER {
// `0x482ed8 jl 0x482fbf` — the whole block, all three legs, sits below this.
return IdleAction::default();
}
if idle >= AUTO_LOGOUT_AFTER {
return IdleAction {
camp: true,
..IdleAction::default()
};
}
IdleAction {
sit: gates.stand_state == 0 && !gates.in_combat && !gates.mounted && !gates.following,
afk: !gates.on_taxi && !gates.afk,
camp: false,
}
}
/// The handler proper. In-world only: `0x482ea0` is `WorldFrame::Render`, so there is no idle
/// timer at the glue screens.
#[allow(clippy::too_many_arguments)]
pub(crate) fn idle_handler(
time: Res<Time<Real>>,
last: Res<LastInput>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_q: Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>,
mut mirror: ResMut<AfkMirror>,
mut chat: ResMut<ChatLog>,
commands: Res<crate::net::NetCommands>,
mut stand: MessageWriter<crate::player::StandStateRequest>,
follow: Res<crate::player::FollowState>,
) {
let idle = time.elapsed().saturating_sub(last.0);
if idle < AUTO_AFK_AFTER {
return;
}
let Some(mut script) = script else {
return;
};
let gates = self_q.iter().next().map(|store| {
let flags = store.0.unit_flags();
IdleGates {
stand_state: store.0.unit_stand_state(),
in_combat: flags & crate::player::UNIT_FLAG_IN_COMBAT != 0,
mounted: store.0.unit_mount_display_id() != 0,
on_taxi: flags & crate::player::UNIT_FLAG_TAXI_FLIGHT != 0,
afk: mirror.is_afk(),
following: follow.guid.is_some(),
}
});
let action = match gates {
Some(g) => idle_action(idle, g),
// No body streamed yet is not "every gate clear": the reference resolves the local player
// (`0x468550` → `0x468460`) inside the block, so the sit and the AFK have nothing to read.
// The camp leg never touches the player object, and survives.
None => IdleAction {
camp: idle >= AUTO_LOGOUT_AFTER,
..IdleAction::default()
},
};
if action.sit {
// Through the ONE setter, like the reference's own call: `player::posture` runs
// `stand_state_refused` on the way (so an idle *swimmer* is not seated, bug B155) and
// drops a request equal to the state it already holds, consulting its own `stand_pending`
// rather than the descriptor — which is what makes re-asking every frame across the server
// round trip produce exactly one `CMSG_STANDSTATECHANGE`.
stand.write(crate::player::StandStateRequest { state: 1 });
}
let strings = |key: &str| crate::ui_chat::combat::global_string(&script, key);
if action.afk {
// `SetAFK(NULL)` is `/afk` with an empty message and the mirror clear — the one row of the
// §12 truth table this can reach, which is why it goes through the same function rather
// than composing its own line: same `MARKED_AFK_MESSAGE % DEFAULT_AFK_MESSAGE` echo, the
// same client-side default substitution reaching the wire, the same optimistic mirror.
let out = afk_line("", *mirror, &strings);
if let Some(line) = out.line {
push_system(&mut chat, line);
}
if let Some(v) = out.mirror {
mirror.0 = v;
}
let _ = commands.0.send(crate::net::ClientCommand::Chat {
kind: crate::net::ChatKind::Afk,
target: None,
text: out.body,
});
}
if action.camp {
if let Some(line) = strings("IDLE_MESSAGE") {
push_system(&mut chat, line);
}
// `0x5ab000` with the game menu's own arguments: `CMSG_LOGOUT_REQUEST`, the server's
// clock, the CAMP dialog. `crate::ui_logout` owns everything from here.
script.queue_session_request(benilla_ui::script::SessionRequest::Logout);
}
}
#[cfg(test)]
mod tests {
use benilla_protocol::ObjectFields;
use bevy::ecs::system::RunSystemOnce;
use super::*;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
/// Descriptor indices, spelled here so a fixture reads like the field it sets.
const UNIT_FLAGS: u16 = 46;
const MOUNT_DISPLAY_ID: u16 = 133;
const BYTES_1: u16 = 138;
const FIVE_MIN: Duration = Duration::from_millis(300_000);
const THIRTY_MIN: Duration = Duration::from_millis(1_800_000);
/// A body that is standing, out of combat, unmounted, off a taxi and not yet AFK — every gate
/// open, so a test that flips one field is testing exactly that field.
fn open() -> IdleGates {
IdleGates::default()
}
/// **The two thresholds, at the millisecond.** `0x493e0` = 300 000 and `0xffe488c0` =
/// −1 800 000, both read off the immediates rather than off "5" and "30".
#[test]
fn the_thresholds_are_the_immediates_not_round_numbers() {
let one_ms = Duration::from_millis(1);
assert_eq!(
idle_action(FIVE_MIN - one_ms, open()),
IdleAction::default(),
"4:59.999 does nothing — `0x482ed8 jl 0x482fbf` leaves the whole block"
);
assert_eq!(
idle_action(FIVE_MIN, open()),
IdleAction {
sit: true,
afk: true,
camp: false
},
"300 000 ms exactly is INSIDE the band: the jcc is `jl`, not `jle`"
);
assert_eq!(
idle_action(THIRTY_MIN - one_ms, open()),
IdleAction {
sit: true,
afk: true,
camp: false
},
);
assert_eq!(
idle_action(THIRTY_MIN, open()),
IdleAction {
camp: true,
..IdleAction::default()
},
"1 800 000 ms exactly takes the camp arm, again on `jl`"
);
}
/// **The bands are exclusive** — the thirty-minute leg neither sits nor marks, because
/// `0x482ee5 jl 0x482f39` is what jumps *into* the five-minute block. A fall-through would
/// seat a player on the frame it asked to log them out.
#[test]
fn the_camp_leg_does_not_also_sit_or_mark_afk() {
for idle in [THIRTY_MIN, THIRTY_MIN + Duration::from_secs(3600)] {
assert_eq!(
idle_action(idle, open()),
IdleAction {
camp: true,
sit: false,
afk: false
},
);
}
}
/// **Each gate refuses its own leg and only its own.** The sit's three and the AFK's two are
/// different tests at different addresses on different fields; the one thing a re-implementer
/// is tempted to do is fold them into a single "can I go idle" predicate.
#[test]
fn the_gates_are_per_leg_never_shared() {
let cases: [(&str, IdleGates, IdleAction); 7] = [
(
"in combat: no sit — but the AFK mark is NOT gated on combat",
IdleGates {
in_combat: true,
..open()
},
IdleAction {
sit: false,
afk: true,
camp: false,
},
),
(
"mounted: no sit, same asymmetry",
IdleGates {
mounted: true,
..open()
},
IdleAction {
sit: false,
afk: true,
camp: false,
},
),
(
"already seated: nothing to ask for, and the AFK is untouched",
IdleGates {
stand_state: 1,
..open()
},
IdleAction {
sit: false,
afk: true,
camp: false,
},
),
(
"on a taxi: no AFK — and the sit is NOT gated on the taxi bit here (its own \
`0x5ed430` guard #3 is what covers that downstream)",
IdleGates {
on_taxi: true,
..open()
},
IdleAction {
sit: true,
afk: false,
camp: false,
},
),
(
"following someone who has stopped walking: no sit — this is the ONLY case the \
fourth gate does work nothing else does, since anything else that leaves \
`[0xc4d888]` non-`0xc` implies translating, which `stand_state_refused` refuses \
downstream. The AFK mark is untouched: it is not one of the sit's gates",
IdleGates {
following: true,
..open()
},
IdleAction {
sit: false,
afk: true,
camp: false,
},
),
(
"already AFK: the anti-repeat, and it does not suppress the sit",
IdleGates {
afk: true,
..open()
},
IdleAction {
sit: true,
afk: false,
camp: false,
},
),
(
"seated AND already AFK — the steady state five minutes in: nothing more happens, \
every frame, for the next twenty-five minutes",
IdleGates {
stand_state: 1,
afk: true,
..open()
},
IdleAction::default(),
),
];
for (why, gates, want) in cases {
assert_eq!(idle_action(FIVE_MIN, gates), want, "{why}");
}
}
/// The steady state is what makes the missing latch safe: run the decision a thousand times
/// with the state each leg creates fed back in, and exactly one sit and one mark come out.
#[test]
fn the_handler_is_self_limiting_without_any_latch() {
let mut gates = open();
let (mut sits, mut marks) = (0, 0);
for frame in 0..1000 {
let a = idle_action(FIVE_MIN + Duration::from_millis(frame), gates);
if a.sit {
sits += 1;
gates.stand_state = 1; // the server's echo of our own CMSG_STANDSTATECHANGE
}
if a.afk {
marks += 1;
gates.afk = true; // `SetAFK` writes the mirror `[0xb6e5cc]` it is gated on
}
}
assert_eq!((sits, marks), (1, 1));
}
/// **The seven writers stamp; auto-repeat does not.** The reference's store sits on the raw
/// message bus ahead of dispatch, so this reads the raw Bevy messages rather than
/// [`crate::bindings::BindingsState`] — a keystroke the chat edit box swallows is still a
/// keystroke, and a player mid-sentence must not be marked away.
#[test]
fn every_writer_stamps_and_auto_repeat_pointedly_does_not() {
use bevy::input::keyboard::{Key, KeyboardInput};
use bevy::input::mouse::{MouseButtonInput, MouseScrollUnit};
use bevy::input::ButtonState;
let stamped = |seed: &dyn Fn(&mut App)| {
let mut app = App::new();
app.init_resource::<Time<Real>>()
.init_resource::<LastInput>()
.init_resource::<AccumulatedMouseMotion>()
.add_message::<KeyboardInput>()
.add_message::<MouseButtonInput>()
.add_message::<MouseWheel>();
app.world_mut()
.resource_mut::<Time<Real>>()
.advance_by(Duration::from_millis(1234));
seed(&mut app);
app.world_mut().run_system_once(stamp_input).unwrap();
app.world().resource::<LastInput>().0
};
let idle = stamped(&|_| {});
assert_eq!(
idle,
Duration::ZERO,
"a frame with no input leaves it alone"
);
let touched = Duration::from_millis(1234);
assert_eq!(
stamped(&|app| {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyW,
logical_key: Key::Character("w".into()),
state: ButtonState::Pressed,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
}),
touched,
"a key press"
);
assert_eq!(
stamped(&|app| {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyW,
logical_key: Key::Character("w".into()),
state: ButtonState::Released,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
}),
touched,
"and a key RELEASE — letting go of a key is input too"
);
assert_eq!(
stamped(&|app| {
app.world_mut().write_message(MouseButtonInput {
button: MouseButton::Left,
state: ButtonState::Pressed,
window: Entity::PLACEHOLDER,
});
}),
touched,
"a mouse button"
);
assert_eq!(
stamped(&|app| {
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 1.0,
window: Entity::PLACEHOLDER,
});
}),
touched,
"the wheel"
);
assert_eq!(
stamped(&|app| {
app.world_mut()
.resource_mut::<AccumulatedMouseMotion>()
.delta = Vec2::new(0.0, 1.0);
}),
touched,
"and bare mouse movement, with no button held"
);
// **The one that is not an omission.** `0x424810` promotes a held key from bus category 8
// to 0xa, and `0x766070` — category 0xa's handler — has no store. So running with W held,
// or sitting on autorun, walks straight into the five-minute auto-AFK. It is the single
// most surprising thing the §5 found, four workers agreed on it, and a re-implementation
// that "fixed" it would diverge from the reference in a way a player would notice.
assert_eq!(
stamped(&|app| {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyW,
logical_key: Key::Character("w".into()),
state: ButtonState::Pressed,
text: None,
repeat: true,
window: Entity::PLACEHOLDER,
});
}),
Duration::ZERO,
"an auto-repeat is category 0xa, which has no store — holding a key is IDLE"
);
}
/// A world with a local player, a live chat log and a wire we can read back.
fn world(
idle_ms: u64,
fields: &[(u16, u32)],
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<Time<Real>>()
.init_resource::<AfkMirror>()
.init_resource::<ChatLog>()
.init_resource::<LastInput>()
.init_resource::<crate::player::FollowState>()
.add_message::<crate::player::StandStateRequest>()
.insert_resource(NetCommands(tx));
// `LastInput` stays at zero and the clock walks forward, so "idle" is just the elapsed.
app.world_mut()
.resource_mut::<Time<Real>>()
.advance_by(Duration::from_millis(idle_ms));
app.world_mut()
.spawn((SelfPlayer, ObjectStore(ObjectFields::from_pairs(fields))));
let script = benilla_ui::script::UiScript::new().unwrap();
// The three GlobalStrings this handler reaches for, exactly as the install ships them.
script
.run(
"MARKED_AFK_MESSAGE = \"You are now AFK: %s\"\n\
DEFAULT_AFK_MESSAGE = \"Away from Keyboard\"\n\
IDLE_MESSAGE = \"You have been inactive for some time and will be logged \
out of the game. If you wish to remain logged in, hit the cancel \
button.\"",
)
.unwrap();
app.insert_non_send_resource(script);
(app, rx)
}
fn lines(app: &App) -> Vec<String> {
app.world().resource::<ChatLog>().pending_lines()
}
/// **The whole five-minute arm, through the real systems.** The sit is a
/// [`crate::player::StandStateRequest`] (so it inherits `stand_state_refused`), the mark is the
/// `/afk` line and the `/afk` packet, and the mirror leads the descriptor exactly as a typed
/// `/afk` would.
#[test]
fn five_minutes_idle_sits_you_down_and_marks_you_afk() {
let (mut app, rx) = world(300_000, &[]);
app.world_mut().run_system_once(idle_handler).unwrap();
assert_eq!(lines(&app), vec!["You are now AFK: Away from Keyboard"]);
assert!(app.world().resource::<AfkMirror>().is_afk());
// The CLIENT's default substitution reaches the wire, never an empty body — the server
// stores this string as the character's auto-reply.
let sent = rx.try_iter().collect::<Vec<_>>();
assert!(
matches!(
sent.as_slice(),
[ClientCommand::Chat { kind: crate::net::ChatKind::Afk, target: None, text }]
if text == "Away from Keyboard"
),
"{sent:?}"
);
let sat = app
.world_mut()
.resource_mut::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>()
.drain()
.map(|r| r.state)
.collect::<Vec<_>>();
assert_eq!(sat, vec![1], "one sit, through the one setter");
}
/// Four minutes fifty-nine is not idle. Nothing at all — no line, no packet, no posture.
#[test]
fn just_under_five_minutes_is_completely_silent() {
let (mut app, rx) = world(299_999, &[]);
app.world_mut().run_system_once(idle_handler).unwrap();
assert!(lines(&app).is_empty());
assert!(!app.world().resource::<AfkMirror>().is_afk());
assert!(rx.try_iter().next().is_none());
}
/// **The thirty-minute arm is a CAMP, and it is not an AFK.** `0x5ab000` is
/// `ClientServices::SendLogout`, the same function the game menu's Logout button reaches, so
/// what comes out here is a [`benilla_ui::script::SessionRequest::Logout`] and nothing else:
/// no `/afk` line, no `/afk` packet, no sit.
#[test]
fn thirty_minutes_idle_camps_instead_of_marking_afk() {
let (mut app, rx) = world(1_800_000, &[]);
app.world_mut().run_system_once(idle_handler).unwrap();
assert_eq!(
lines(&app),
vec![
"You have been inactive for some time and will be logged out of the game. If you \
wish to remain logged in, hit the cancel button."
],
"IDLE_MESSAGE goes out unformatted — `0x482f1a` hands `GetText`'s result straight to \
the chat sink, with no snprintf in between. It carries no format slot, and the \
sentence it does carry (\"hit the cancel button\") is the CAMP dialog's, which is \
the string's own confirmation that this leg is a logout REQUEST and not a kick."
);
assert!(
!app.world().resource::<AfkMirror>().is_afk(),
"the camp leg pointedly does NOT mark AFK"
);
assert!(rx.try_iter().next().is_none(), "and sends no chat packet");
assert!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_session_requests()
== vec![benilla_ui::script::SessionRequest::Logout],
);
assert!(app
.world_mut()
.resource_mut::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>()
.drain()
.next()
.is_none());
}
/// The descriptor is what the gates read — a body in combat is not seated, and one on a taxi
/// is not marked. Driven through the real system so the field decode is under test too.
#[test]
fn the_descriptor_bits_reach_the_gates() {
// In combat (`UNIT_FIELD_FLAGS` bit 19): marked, not seated.
let (mut app, _rx) = world(300_000, &[(UNIT_FLAGS, 0x0008_0000)]);
app.world_mut().run_system_once(idle_handler).unwrap();
assert!(app.world().resource::<AfkMirror>().is_afk());
assert!(app
.world_mut()
.resource_mut::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>()
.drain()
.next()
.is_none());
// On a taxi (bit 20): seated, not marked.
let (mut app, rx) = world(300_000, &[(UNIT_FLAGS, 0x0010_0000)]);
app.world_mut().run_system_once(idle_handler).unwrap();
assert!(!app.world().resource::<AfkMirror>().is_afk());
assert!(rx.try_iter().next().is_none());
assert_eq!(
app.world_mut()
.resource_mut::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>()
.drain()
.count(),
1
);
// Mounted: marked, not seated.
let (mut app, _rx) = world(300_000, &[(MOUNT_DISPLAY_ID, 6080)]);
app.world_mut().run_system_once(idle_handler).unwrap();
assert!(app.world().resource::<AfkMirror>().is_afk());
assert!(app
.world_mut()
.resource_mut::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>()
.drain()
.next()
.is_none());
// Already sitting (`UNIT_FIELD_BYTES_1` byte 0 = 1): marked, no second ask.
let (mut app, _rx) = world(300_000, &[(BYTES_1, 1)]);
app.world_mut().run_system_once(idle_handler).unwrap();
assert!(app.world().resource::<AfkMirror>().is_afk());
assert!(app
.world_mut()
.resource_mut::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>()
.drain()
.next()
.is_none());
}
}
+98 -1
View File
@@ -71,14 +71,26 @@ enum LogoutSignal {
/// The pending session-exit, and the signals owed to the UI.
///
/// `quitting` is the ONE piece of local state the whole arc needs: the wire cannot tell a logout
/// `quitting` is the piece of local state the arc's *meaning* needs: the wire cannot tell a logout
/// from a quit (both are `CMSG_LOGOUT_REQUEST`), so which dialog to show and whether to exit the
/// process at the end is remembered here. It is cleared by every terminal edge — a refusal, a
/// cancel, or the exit itself — so a cancelled quit can never leave a client that dies on the next
/// unrelated logout.
///
/// `pending` is the reference's own **`[session+0x1b1d]`** (decision 2092), which this module knew
/// about in prose — `apply_cancelled`'s doc has always called it "the same pending byte the
/// refusal arm clears" — and never modelled. `0x5ab000` bails on it *silently* when `force == 0`,
/// so a second request while one is in flight is not a second packet. That gate was invisible
/// while the only caller was a button a human presses once; the idle handler (2092) asks every
/// frame past thirty minutes, and without the latch it would put a `CMSG_LOGOUT_REQUEST` on the
/// wire sixty times a second until something else stamped the idle clock.
#[derive(Resource, Default)]
pub(crate) struct LogoutState {
quitting: bool,
/// `[session+0x1b1d]` — a logout request is out and unanswered. Set by the non-forced path,
/// bypassed *and not set* by `ForceLogout` (`0x5aaff0`, decision 1963), cleared by every
/// terminal edge.
pending: bool,
signals: Vec<LogoutSignal>,
}
@@ -92,6 +104,9 @@ impl LogoutState {
info!("logout: server answered reason={reason} instant={instant}");
if reason != 0 {
self.quitting = false;
// `0x5aaf21`'s arm: `DisplayError(ERR_LOGOUT_FAILED)` **and clear the pending flag**,
// which is what lets the player try again once they leave combat.
self.pending = false;
self.signals.push(LogoutSignal::Refused);
return;
}
@@ -110,6 +125,7 @@ impl LogoutState {
/// `LOGOUT_CANCEL` off the same pending byte the refusal arm clears (decision 1821).
pub(crate) fn apply_cancelled(&mut self) {
self.quitting = false;
self.pending = false;
self.signals.push(LogoutSignal::Cancelled);
}
}
@@ -156,6 +172,12 @@ fn drain_logout(
for request in script.take_session_requests() {
match request {
SessionRequest::Logout | SessionRequest::Quit => {
// `0x5ab000`'s pending-logout bail, and it is **silent** — no error line, no
// event, nothing on the wire. It sits ABOVE `0x5ab053`'s store of the quit byte,
// so a refused request does not even get to change camp-into-quit.
if logout.pending {
continue;
}
logout.quitting = request == SessionRequest::Quit;
// Not in the world (or the socket is gone): there is nothing to log out OF, so a
// quit is just an exit and a logout is a no-op. The reference does the same — its
@@ -169,6 +191,7 @@ fn drain_logout(
logout.quitting = false;
}
} else {
logout.pending = true;
// Announced because what happens next is entirely the server's call — instant
// or a 20 s countdown — and a silent request looks identical to a dropped one.
info!(
@@ -183,6 +206,7 @@ fn drain_logout(
// harmless (vmangos ignores it). `quitting` clears here rather than on the ack so a
// cancel with a dead socket still can't leave a quit armed.
logout.quitting = false;
logout.pending = false;
let _ = commands.0.send(ClientCommand::LogoutCancel);
}
SessionRequest::ForceQuit => {
@@ -191,6 +215,9 @@ fn drain_logout(
}
// `CMSG_PLAYER_LOGOUT`, the forced flavour: the dispatcher's own gate is a live
// in-world session, and nothing happens without one (decision 1963).
// `0x5aaff0` calls the dispatcher with `force = 1`, which BYPASSES the pending bail
// and does NOT set the latch (wow-re `staticpopup-dialog-bindings.md` §4) — so a
// forced logout is exactly the escape hatch from a stuck pending one.
SessionRequest::ForceLogout => {
if self_guid.0.is_some() {
info!("logout: forced");
@@ -220,6 +247,9 @@ fn exit_on_logout_complete(
if logged_out.read().count() == 0 {
return;
}
// The request is answered — and on a logout that lands us at character select this is the edge
// that must disarm it, or the next character's first Logout would be swallowed.
logout.pending = false;
if logout.quitting {
logout.quitting = false;
info!("logout: world session ended — exiting");
@@ -244,6 +274,8 @@ impl Plugin for UiLogoutPlugin {
#[cfg(test)]
mod tests {
use bevy::ecs::system::RunSystemOnce;
use super::*;
/// The response table (module docs): refusal speaks and disarms, instant is silent, and the
@@ -259,6 +291,7 @@ mod tests {
let mut s = LogoutState {
quitting: true,
pending: true,
signals: Vec::new(),
};
s.apply_response(0, false);
@@ -268,11 +301,74 @@ mod tests {
// minutes later and deliberate, would silently kill the process instead.
let mut s = LogoutState {
quitting: true,
pending: true,
signals: Vec::new(),
};
s.apply_response(1, false);
assert_eq!(s.signals, vec![LogoutSignal::Refused]);
assert!(!s.quitting);
assert!(
!s.pending,
"the refusal arm clears the pending byte — otherwise a logout refused in combat \
would never be retryable"
);
}
/// **The pending latch, both directions** (decision 2092) — `0x5ab000` bails silently on
/// `[session+0x1b1d]`, and every terminal edge clears it. Driven through the real
/// [`drain_logout`] rather than the state alone, because the bail lives in the drain and the
/// thing under test is that the second ask puts nothing on the wire.
#[test]
fn a_second_logout_while_one_is_pending_is_silent() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<LogoutState>()
.init_resource::<crate::ui_script::ReloadUiPending>()
.init_resource::<crate::cinematic::Cinematic>()
.add_message::<AppExit>()
.add_message::<LoggedOutMessage>()
.insert_resource(SelfGuid(Some(0x4000_0000_0000_0009)))
.insert_resource(NetCommands(tx));
app.insert_non_send_resource(UiScript::new().unwrap());
let ask = |app: &mut App, request| {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.queue_session_request(request);
app.world_mut()
.run_system_once(drain_logout)
.expect("drain");
};
let sent = |rx: &crossbeam_channel::Receiver<ClientCommand>| rx.try_iter().count();
ask(&mut app, SessionRequest::Logout);
assert_eq!(sent(&rx), 1, "the first ask goes out");
assert!(app.world().resource::<LogoutState>().pending);
// The idle handler's shape: ask again, and again, while the server has not answered.
for _ in 0..60 {
ask(&mut app, SessionRequest::Logout);
}
assert_eq!(sent(&rx), 0, "and not one more packet, nor any error line");
// `ForceLogout` bypasses the bail and does not set the latch (`0x5aaff0`, force = 1).
ask(&mut app, SessionRequest::ForceLogout);
assert_eq!(sent(&rx), 1, "the forced flavour is the escape hatch");
// A cancel disarms, and the next ask is heard again.
ask(&mut app, SessionRequest::CancelLogout);
assert_eq!(sent(&rx), 1, "CMSG_LOGOUT_CANCEL");
assert!(!app.world().resource::<LogoutState>().pending);
ask(&mut app, SessionRequest::Logout);
assert_eq!(sent(&rx), 1);
// …and so does the completion, so the next character's first Logout is not swallowed.
app.world_mut().resource_mut::<LogoutState>().pending = true;
app.world_mut().write_message(LoggedOutMessage);
app.world_mut()
.run_system_once(exit_on_logout_complete)
.expect("complete");
assert!(!app.world().resource::<LogoutState>().pending);
}
/// The cancel ack disarms a quit too — the same "no armed quit survives a terminal edge" rule.
@@ -280,6 +376,7 @@ mod tests {
fn a_cancel_disarms_a_pending_quit() {
let mut s = LogoutState {
quitting: true,
pending: true,
signals: Vec::new(),
};
s.apply_cancelled();
+131 -4
View File
@@ -345,10 +345,12 @@ fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
/// allowed to compute that path (0954) — which is already `None` under `$WOW_CAPTURE`, so a
/// deterministic baseline cannot depend on what somebody has installed (0008).
///
/// `pub(crate)` since 1322: the texture side needs the same folder — the sprite decoder's
/// `Interface\AddOns\` loose-file resolve and the `SetTexture` probe both map that virtual prefix
/// onto this root (`ui_script::lifecycle::install_texture_resolvers`), so there is exactly one
/// answer to "where do addons live" however the question is asked.
/// `pub(crate)` since 1322: every asset an addon ships needs the same folder — the sprite
/// decoder's `Interface\AddOns\` loose-file resolve, the `SetTexture` probe, and (2103) the font
/// engine's face loader plus the `SetFont` probe all map that virtual prefix onto this root, so
/// there is exactly one answer to "where do addons live" however the question is asked. The first
/// three are wired in one place (`ui_script::lifecycle::install_addon_asset_resolvers`); the face
/// loader reads it here directly, at engine construction, because it is not a VM concern.
pub(crate) fn root() -> Option<PathBuf> {
root_from(crate::local_state::home())
}
@@ -2039,6 +2041,131 @@ mod tests {
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **The `/msbt` shape** (decision 2102): a LoadOnDemand addon whose `## Dependencies:` names
/// an addon the STARTUP walk already loaded.
///
/// This is the whole demand-load path a player actually meets — Mik's Scrolling Battle Text
/// ships its options as a separate `## LoadOnDemand: 1` package with
/// `## Dependencies: MikScrollingBattleText`, and `/msbt` reaches it through
/// `UIParentLoadAddOn`, whose failure message is the red
/// `Couldn't load %s: <ADDON_ + reason>` dialog. The dependency being **already loaded** is the
/// half nothing covered: a dependency the gate finds unloaded and not LoadOnDemand is
/// `DEP_NOT_DEMAND_LOADED`, so the startup walk's `mark_addon_loaded` stamp is what stands
/// between this and a dialog. (The addon harness answered `DEP_NOT_DEMAND_LOADED` here for
/// exactly that reason until 2102 gave it the same stamp.)
#[test]
fn a_load_on_demand_addon_loads_on_top_of_an_already_loaded_dependency() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("lod-dep");
write_addon(
&home,
"Base",
"## Interface: 11200\n## Title: Base\nbase.lua\n",
&[("base.lua", "BaseRan = true")],
);
write_addon(
&home,
"BaseOptions",
"## Interface: 11200\n## Title: Base Options\n## Dependencies: Base\n ## LoadOnDemand: 1\nopts.lua\n",
&[("opts.lua", "OptsRan = BaseRan")],
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
// The dependency loaded at startup and the registry knows it — the stamp this rests on.
assert_eq!(
script.eval::<bool>("return IsAddOnLoaded('Base') == 1").ok(),
Some(true),
"a startup-loaded addon must read as loaded, or every LoadOnDemand dependent of it answers DEP_NOT_DEMAND_LOADED"
);
assert_eq!(
script.eval::<bool>("return OptsRan == nil").ok(),
Some(true),
"and the LoadOnDemand package has not run"
);
// `UIParentLoadAddOn`'s own two lines, in order: load, then use what it created.
assert_eq!(
script
.eval::<Vec<String>>(
"local loaded, reason = LoadAddOn('BaseOptions') \
return { tostring(loaded), tostring(reason), tostring(OptsRan), \
tostring(IsAddOnLoaded('BaseOptions')) }"
)
.ok(),
Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]),
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **A `.toc` line naming a file the package does not ship does not stop a demand load**
/// (decision 2107) — 1450's rule, applied by `LoadAddOn` and not only by the startup walk.
///
/// The reference logs `Couldn't open %s` and carries on (wow-re
/// `xml-toc-path-resolution.md` §4). Ours sent the miss to the **script-error** channel
/// instead, and the corpus paid for it the moment `LoadAddOn` became reachable at scale:
/// FuBar's `LoadLoadOnDemandPlugins` demand-loads 55 plugins whose `.toc`s each list an
/// `AmmoFuLocale-koKR.lua` none of them ships, and every one of them scored a session
/// failure for a file the real client shrugs at. 150 → 89 on the vanilla corpus.
#[test]
fn a_missing_manifest_file_does_not_fail_a_demand_load() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("lod-missing-file");
write_addon(
&home,
"Holey",
"## Interface: 11200\n## Title: Holey\n## LoadOnDemand: 1\n\
Locale-koKR.lua\nreal.lua\n",
// `Locale-koKR.lua` is listed and NOT written — the FuBar plugin shape exactly.
&[("real.lua", "HoleyRan = true")],
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
assert_eq!(
script
.eval::<Vec<String>>(
"local loaded, reason = LoadAddOn('Holey') \
return { tostring(loaded), tostring(reason), tostring(HoleyRan), \
tostring(IsAddOnLoaded('Holey')) }"
)
.ok(),
Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]),
"the addon loads, its remaining files run, and it reads as loaded"
);
// The miss is a WARNING on the host channel, never a script error — the harness's session
// column and `smoke.sh`'s zero-ERROR count both read the error channel, and 1450's whole
// point is that a player's broken package cannot redden either.
assert!(
script.take_errors().is_empty(),
"a missing manifest entry must not reach the script-error channel"
);
let warnings = script.take_warnings();
assert!(
warnings
.iter()
.any(|w| w.contains("Holey/Locale-koKR.lua") && w.contains("not found")),
"…but it must still be said out loud: {warnings:?}"
);
// …and retained where the player can read it (1495), as the walk's misses are.
assert!(
script
.diagnostics()
.iter()
.any(|d| d.message.contains("Holey/Locale-koKR.lua")),
"the miss is retained in the diagnostic log"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// The reason tokens are the reference's own, for the cases a caller branches on.
///
/// They are not free-form strings: the reference splices them into
@@ -203,10 +203,13 @@ fn delete_item_confirm_no_clears_without_destroying() {
);
// Count repaints from here — the No-click path must trigger one via the event, not a click.
// 5.0's vararg spelling: the implicit `arg` table forwarded with `unpack`, because `...` as a
// VALUE is not in this VM's grammar — it is not in the 1.12 client's (decision 2101).
s.run(
"repaints = 0\n\
local real = ContainerFrame_Update\n\
ContainerFrame_Update = function(...) repaints = repaints + 1; return real(...) end",
ContainerFrame_Update = function(...) repaints = repaints + 1; \
return real(unpack(arg, 1, arg.n)) end",
)
.unwrap();
+40 -12
View File
@@ -56,7 +56,7 @@ fn install_boot_vm(world: &mut World) {
return;
}
};
install_texture_resolvers(world, &mut script);
install_addon_asset_resolvers(world, &mut script);
load_global_strings(world, &script);
load_emote_tokens(world, &script);
if ui_wanted(world) {
@@ -67,13 +67,16 @@ fn install_boot_vm(world: &mut World) {
world.insert_non_send_resource(script);
}
/// Wire up the halves of `Interface\AddOns\` texture art (decision 1322): the sprite decoder's
/// **loose-file root** (so addon-shipped BLP/TGA files render at all — the store's
/// [`benilla_assets::WorldAssets::set_loose_sprite_root`]) and the VM's **texture probe** (so the
/// Wire up the halves of `Interface\AddOns\` **art and fonts** (decisions 1322, 2103): the sprite
/// decoder's **loose-file root** (so addon-shipped BLP/TGA files render at all — the store's
/// [`benilla_assets::WorldAssets::set_loose_addon_root`]), the VM's **texture probe** (so the
/// path form of `SetTexture` can answer the reference's 1|nil load verdict — Atlas picks its map
/// art by that return). Both resolve the same one folder ([`addons::root`], hermetic-`None` under
/// `$WOW_CAPTURE`), and the probe walks the same [`benilla_assets::sprite_candidates`] the
/// renderer decodes with, so the verdict the Lua caller gets is the verdict the screen shows.
/// art by that return), and the VM's **font probe** (the same 1|nil for `SetFont`, whose nil is a
/// load failure — `!OmniCC` uses it as a font-file validity check). All resolve the same one folder
/// ([`addons::root`], hermetic-`None` under `$WOW_CAPTURE`), and each probe walks exactly the
/// store its renderer reads — [`benilla_assets::sprite_candidates`] for art, the chain-then-folder
/// pair [`crate::ui_text`]'s face loader uses for fonts — so the verdict the Lua caller gets is the
/// verdict the screen shows.
///
/// The **size probe** beside it is the same oracle answering a different question — how many texels
/// wide and tall is the art — which is what lets a region that authored no size on an axis take
@@ -82,23 +85,30 @@ fn install_boot_vm(world: &mut World) {
/// is the number the screen shows, and memoises per texture key: the ask is per zero-size region
/// per resolve, and the answer cannot change for a key that already read.
///
/// No `WorldAssets` (no client data) means no backend: nothing to install, and the VM's path form
/// keeps answering nil — the engine-less truth.
fn install_texture_resolvers(world: &mut World, script: &mut UiScript) {
/// No `WorldAssets` (no client data) means no backend: nothing to install, so `SetTexture`'s path
/// form keeps answering nil and `SetFont`'s keeps answering 1 — each store's own engine-less truth
/// (`Model::texture_probe` / `Model::font_probe` carry why the two defaults differ).
///
/// Both probes test **existence**, not decode success: a file that is there but will not decode
/// answers 1 and draws nothing. That is 1322's stated approximation and it is unchanged here; the
/// renderer's own `texture miss` / `font miss` WARN is what names such a file.
fn install_addon_asset_resolvers(world: &mut World, script: &mut UiScript) {
let root = addons::root();
let Some(mut assets) = world.get_resource_mut::<benilla_assets::WorldAssets>() else {
return;
};
assets.set_loose_sprite_root(root.clone());
assets.set_loose_addon_root(root.clone());
let chain = assets.chain.clone();
let size_chain = chain.clone();
let size_root = root.clone();
let font_chain = chain.clone();
let font_root = root.clone();
script.set_texture_probe(Box::new(move |path| {
benilla_assets::sprite_candidates(path).iter().any(|c| {
chain.lock_recover().contains(c)
|| root
.as_deref()
.is_some_and(|r| benilla_assets::loose_sprite_file(r, c).is_some())
.is_some_and(|r| benilla_assets::loose_addon_file(r, c).is_some())
})
}));
// Keyed by the reference string exactly as the region carries it, so the hit path allocates
@@ -116,6 +126,24 @@ fn install_texture_resolvers(world: &mut World, script: &mut UiScript) {
sizes.borrow_mut().insert(path.to_string(), measured);
measured
}));
// The font oracle. Existence over the same two stores, in the same order, that
// `ui_text::engine`'s face loader reads — a `SetFont` that answers 1 and then draws Friz
// because the two disagreed would be worse than no probe at all. Memoised for the same reason
// the size probe is: an addon can call `SetFont` per animated string per frame (MSBT does),
// and a path's answer cannot change mid-session.
let seen: std::cell::RefCell<std::collections::HashMap<String, bool>> =
std::cell::RefCell::new(std::collections::HashMap::new());
script.set_font_probe(Box::new(move |path| {
if let Some(&cached) = seen.borrow().get(path) {
return cached;
}
let found = font_chain.lock_recover().contains(path)
|| font_root.as_deref().is_some_and(|r| {
benilla_assets::loose_addon_file(r, &benilla_assets::normalize_path(path)).is_some()
});
seen.borrow_mut().insert(path.to_string(), found);
found
}));
}
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
@@ -1206,14 +1206,15 @@ fn the_shipped_setter_passes_exactly_four_arguments() {
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
// `r##`: the Lua contains `select("#", ...)`, and `"#` would close a single-hash raw string.
// 5.0's `arg.n`, not `select("#", ...)`: `...` as a value is not in this VM's grammar, because
// it is not in the 1.12 client's (decision 2101).
s.run(
r##"
r#"
BENILLA_TEST_TOGGLE_ARGC = nil
function SetActionBarToggles(...)
BENILLA_TEST_TOGGLE_ARGC = select("#", ...)
BENILLA_TEST_TOGGLE_ARGC = arg.n
end
"##,
"#,
)
.unwrap();
// The shipped setter is the Action Bars row's own closure (OptionsFrame.xml), which is
+172 -9
View File
@@ -205,13 +205,33 @@ struct CacheStats {
// The engine
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The two stores a font path can come from — the same pair, in the same order, that
/// [`benilla_assets::WorldAssets`]'s sprite decoder walks (decision 1322, generalised by 2103).
///
/// Chain first: no MPQ holds an `Interface\AddOns\` path, so the order is unobservable and every
/// client path stays on exactly the code it always ran.
struct FontSource {
chain: Arc<Mutex<benilla_formats::Chain>>,
/// The ONE AddOns root, from `ui_script::addons::root()` — `None` under `$WOW_CAPTURE`, which
/// is what keeps a capture hermetic for free.
loose_root: Option<std::path::PathBuf>,
}
/// The font engine: faces, the two caches, and the texture pages.
pub(crate) struct TextEngine {
font_system: FontSystem,
swash: SwashCache,
faces: Vec<Face>,
/// Blizzard font path (lowercased) → index into [`Self::faces`].
/// Font path (lowercased) → index into [`Self::faces`]. Seeded with the four client faces and
/// grown on demand by [`Self::face_for`] — an addon's own TTF is a path like any other.
path_to_face: HashMap<String, usize>,
/// Where a face the map does not carry is read from: the patch chain, then the one AddOns
/// folder for `Interface\AddOns\…` paths ([`FontSource`], decision 2103). `None` in a VM
/// with no install, which is the only state in which a font path cannot resolve at all.
source: Option<FontSource>,
/// Paths that failed to load, so a miss costs one read and one WARN rather than one per
/// measure. Cleared by nothing: a font file does not appear mid-session.
missing_fonts: HashSet<String>,
/// The fallback face (Friz Quadrata) for a FontString with no/unknown font path.
default_face: usize,
/// The window's `scale_factor` — physical px per logical px. Every raster size derives from it
@@ -286,6 +306,11 @@ impl TextEngine {
swash: SwashCache::new(),
faces,
path_to_face,
source: Some(FontSource {
chain: world_assets.chain.clone(),
loose_root: crate::ui_script::addons::root(),
}),
missing_fonts: HashSet::new(),
default_face,
dpi,
chars: HashMap::new(),
@@ -299,11 +324,61 @@ impl TextEngine {
})
}
/// The face a Blizzard font path resolves to, or the fallback.
pub(super) fn face_for(&self, path: Option<&str>) -> usize {
path.and_then(|p| self.path_to_face.get(&p.to_ascii_lowercase()))
.copied()
.unwrap_or(self.default_face)
/// The face a font path resolves to, loading it on first use, or the fallback.
///
/// **The four client TTFs are the ones we know the names of, not the ones that exist**
/// (decision 2103). `SetFont`/`<FontString font=>`/`CreateFont` take an arbitrary path, and an
/// addon that ships its own faces — Mik's Scrolling Battle Text ships thirty-one — names them
/// `Interface\Addons\<Addon>\Fonts\<x>.ttf`. Before this, every one of those silently
/// resolved to Friz Quadrata: the text drew, in the wrong face, with nothing anywhere saying
/// so. So a path the map does not carry is READ (chain, then the loose AddOns folder — the
/// same two stores in the same order 1322 gave textures) and registered.
///
/// A path that will not load is remembered in [`Self::missing_fonts`] and warns once, then
/// falls back to Friz exactly as before — the reference degrades the same way (a `CGxFont`
/// that fails to build leaves the string on the font object it inherits).
pub(super) fn face_for(&mut self, path: Option<&str>) -> usize {
let Some(p) = path.filter(|p| !p.is_empty()) else {
return self.default_face;
};
let key = p.to_ascii_lowercase();
if let Some(&i) = self.path_to_face.get(&key) {
return i;
}
if self.missing_fonts.contains(&key) {
return self.default_face;
}
match self.load_face(p, &key) {
Some(i) => i,
None => {
warn!(
"ui_text: font miss: '{p}' does not resolve in the patch chain or the AddOns \
folder — falling back to {}",
CLIENT_FONTS[0]
);
self.missing_fonts.insert(key);
self.default_face
}
}
}
/// Read and register one font path. `None` if no store has it or the bytes are not a face.
fn load_face(&mut self, path: &str, key: &str) -> Option<usize> {
let source = self.source.as_ref()?;
let bytes = read_font_bytes(source, path)?;
let ascent_ratio = hhea_ascent_ratio(&bytes).unwrap_or(0.794);
let (id, family) = register_font(&mut self.font_system, bytes)
.inspect_err(|e| warn!("ui_text: failed to register {path}: {e:#}"))
.ok()?;
let index = self.faces.len();
self.faces.push(Face {
id,
family,
ascent_ratio,
});
self.path_to_face.insert(key.to_string(), index);
info!("ui_text: loaded font face {path}");
Some(index)
}
/// **The size law.** A logical height becomes the exact integer device-pixel size it will be
@@ -361,8 +436,9 @@ impl TextEngine {
/// The baseline-ascender fraction for a font path — the `[CGxFont+0x17c]` load_param, for a
/// caller that already holds the lock.
pub(crate) fn ascent_ratio(&self, path: Option<&str>) -> f32 {
self.ascent_ratio_of(self.face_for(path))
pub(crate) fn ascent_ratio(&mut self, path: Option<&str>) -> f32 {
let face = self.face_for(path);
self.ascent_ratio_of(face)
}
/// Make sure every character of `text` is in the caches at this `(face, ppem, radius)`,
@@ -672,6 +748,22 @@ impl UiFontAtlas {
}
}
/// Read one font path from the two stores, chain first.
///
/// The loose leg goes through the same [`benilla_assets::loose_addon_file`] the sprite decoder
/// uses, so it inherits its rules whole: only `Interface\AddOns\` paths reach the folder, the
/// component walk is case-insensitive (MSBT names its own files `Interface\Addons\…` with a
/// lowercase `d`, and ships `mailrays.TTF` while its table says `mailrays.ttf`), and a
/// dot-component is refused before any filesystem call.
fn read_font_bytes(source: &FontSource, path: &str) -> Option<Vec<u8>> {
if let Ok(bytes) = source.chain.lock_recover().read(path) {
return Some(bytes);
}
let root = source.loose_root.as_deref()?;
let file = benilla_assets::loose_addon_file(root, &benilla_assets::normalize_path(path))?;
std::fs::read(file).ok()
}
/// A real-font engine for a test: the client faces, read through the app's own patch chain. `None`
/// when there is no install, or the chain or a face will not open — every caller **skips** rather
/// than fails (`wow_data_or_skip!`).
@@ -701,6 +793,12 @@ pub(super) fn test_engine(dpi: f32) -> Option<TextEngine> {
swash: SwashCache::new(),
faces,
path_to_face,
// The same chain the four client faces were just read from, so a test can ask for a fifth.
source: Some(FontSource {
chain: Arc::new(Mutex::new(chain)),
loose_root: None,
}),
missing_fonts: HashSet::new(),
default_face,
dpi,
chars: HashMap::new(),
@@ -1032,7 +1130,7 @@ mod ppem_tests {
/// Each face resolves to itself, and an unknown path falls back to Friz.
#[test]
fn a_font_path_resolves_to_its_own_face() {
let Some(e) = engine_or_skip() else {
let Some(mut e) = engine_or_skip() else {
return;
};
let friz = e.face_for(Some(TEST_FACES[0]));
@@ -1041,4 +1139,69 @@ mod ppem_tests {
assert_eq!(friz, e.face_for(Some("fonts\\frizqt__.ttf")), "case-folded");
assert_ne!(friz, e.face_for(Some(TEST_FACES[1])), "ARIALN is its own");
}
/// **An addon-shipped TTF loads out of the ONE AddOns root** (decision 2103) — the leg the bug
/// was: MSBT ships thirty-one faces and names them `Interface\\Addons\\…\\Fonts\\<x>.ttf`,
/// a shape no MPQ carries, and every one of them silently drew as Friz Quadrata.
///
/// The file is a real TTF (a client face, copied) because the assertion is that it REGISTERED,
/// not merely that a path matched; the reference spelling deliberately mismatches the folder's
/// case on every component, as the ecosystem's do (MSBT writes `Addons`, ships `mailrays.TTF`
/// and asks for `mailrays.ttf`).
#[test]
fn an_addon_shipped_font_loads_out_of_the_addons_root() {
let Some(mut e) = engine_or_skip() else {
return;
};
let bytes = {
let source = e.source.as_ref().expect("the test engine carries a chain");
let chain = source.chain.lock_recover();
chain.read(TEST_FACES[2]).expect("MORPHEUS is in the chain")
};
let root = std::env::temp_dir().join(format!("benilla-addon-font-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let fonts = root.join("MikScrollingBattleText").join("Fonts");
std::fs::create_dir_all(&fonts).unwrap();
std::fs::write(fonts.join("porky.TTF"), &bytes).unwrap();
e.source.as_mut().unwrap().loose_root = Some(root.clone());
let friz = e.face_for(Some(TEST_FACES[0]));
let addon = e.face_for(Some(
"Interface\\Addons\\mikscrollingbattletext\\fonts\\porky.ttf",
));
assert_ne!(
friz, addon,
"an addon's own face must not fall back to Friz"
);
assert!(
e.missing_fonts.is_empty(),
"and it must not be recorded as a miss"
);
// A sibling path under the same root that is not there is still a miss — the folder is a
// store, not a wildcard.
assert_eq!(
friz,
e.face_for(Some(
"Interface\\Addons\\MikScrollingBattleText\\Fonts\\nope.ttf"
))
);
let _ = std::fs::remove_dir_all(&root);
}
/// A path that resolves nowhere falls back to Friz and is remembered, so the read and the WARN
/// happen once rather than once per measure.
#[test]
fn an_unresolvable_font_path_is_remembered_as_missing() {
let Some(mut e) = engine_or_skip() else {
return;
};
let friz = e.face_for(Some(TEST_FACES[0]));
let bogus = "Interface\\Addons\\NoSuchAddon\\Fonts\\nope.ttf";
assert_eq!(friz, e.face_for(Some(bogus)));
assert!(e.missing_fonts.contains(&bogus.to_ascii_lowercase()));
assert_eq!(friz, e.face_for(Some(bogus)), "and the second ask is free");
// An empty path is the `SetFont("")` case and is not a miss — it is "no face named".
assert_eq!(friz, e.face_for(Some("")));
assert!(!e.missing_fonts.contains(""));
}
}
+1 -1
View File
@@ -331,7 +331,7 @@ fn the_reported_book_crest_draws_at_the_blps_own_size() {
let chain = std::sync::Mutex::new(benilla_formats::open_chain(&data).expect("open chain"));
let mut s = UiScript::new().unwrap();
// The host oracle, wired exactly as `ui_script::lifecycle::install_texture_resolvers` wires the
// The host oracle, wired exactly as `ui_script::lifecycle::install_addon_asset_resolvers` wires the
// live one: the same decoder, so the size the layout resolves with is the size the screen shows.
s.set_texture_size_probe(Box::new(move |path| {
benilla_assets::sprite_dimensions(&chain, None, path)
+17 -12
View File
@@ -78,7 +78,7 @@ pub struct WorldAssets {
/// here — the caller supplies the RGBA — so this stays a cache, not a second art pipeline.
generated: HashMap<&'static str, Handle<Image>>,
/// The **loose-file root for `Interface\AddOns\` sprite paths** — the AddOns folder, installed
/// by the app ([`Self::set_loose_sprite_root`]), `None` until then and in every capture run
/// by the app ([`Self::set_loose_addon_root`]), `None` until then and in every capture run
/// (the app resolves it through `local_state`, which is hermetic under `$WOW_CAPTURE`).
///
/// Addon art never lives in an MPQ: the reference's Storm open reads loose files from the game
@@ -205,7 +205,7 @@ pub fn sprite_dimensions(
/// can't poison the second.
///
/// Each candidate is asked of **two stores**: the patch chain, then — for `Interface\AddOns\`
/// paths — the loose addon folder ([`loose_sprite_file`], decision 1322). The chain never holds an
/// paths — the loose addon folder ([`loose_addon_file`], decision 1322). The chain never holds an
/// `AddOns\` path (Blizzard's own `Blizzard_*` stubs aside, addon art only exists on disk), so the
/// order between the stores is unobservable; chain-first keeps every non-addon path on exactly the
/// code it always ran.
@@ -222,7 +222,7 @@ fn decode_sprite(
return Some(decoded);
}
}
if let Some(file) = loose_root.and_then(|root| loose_sprite_file(root, candidate)) {
if let Some(file) = loose_root.and_then(|root| loose_addon_file(root, candidate)) {
if let Ok(decoded) = std::fs::read(&file)
.map_err(anyhow::Error::from)
.and_then(|bytes| decode_sprite_bytes(&bytes))
@@ -255,16 +255,21 @@ fn decode_sprite_bytes(bytes: &[u8]) -> anyhow::Result<(u32, u32, Vec<u8>)> {
}
}
/// Map a **normalized** sprite candidate (`interface\addons\<addon>\<…>.blp`, lowercase,
/// Map a **normalized** addon-relative path (`interface\addons\<addon>\<…>`, lowercase,
/// backslashed — [`normalize_path`] has run) onto a file under the loose addon root, or `None` if
/// it is not an `Interface\AddOns\` path or nothing is there.
///
/// **Not sprite-specific, and named for that** (decision 2103): 1322 built this for addon-shipped
/// BLP/TGA art, and an addon's own TTFs reach the client by exactly the same route — a loose file
/// under the one AddOns root, named by a virtual `Interface\AddOns\…` path no MPQ carries. One
/// resolver, one prefix rule, one sandbox.
///
/// The walk matches each component **case-insensitively** (exact join first — free on the
/// case-insensitive filesystems macOS installs default to — then a `read_dir` scan): the reference
/// is a Windows client and addons reference their own art in arbitrary case. The candidate cannot
/// escape the root — `normalize_path` leaves no `/`, and any dot-component is refused before a
/// filesystem call happens (same lexical posture as `ui_script::addons::read_under`).
pub fn loose_sprite_file(root: &Path, candidate: &str) -> Option<PathBuf> {
pub fn loose_addon_file(root: &Path, candidate: &str) -> Option<PathBuf> {
let rel = candidate.strip_prefix("interface\\addons\\")?;
let mut at = root.to_path_buf();
for comp in rel.split('\\') {
@@ -370,7 +375,7 @@ impl WorldAssets {
/// [`Self::loose_root`] field doc. Called by the app once it knows the AddOns folder; evicts
/// cached **misses** so a path asked before the root existed gets a second look (a cached hit
/// can only have come from the chain and stays right).
pub fn set_loose_sprite_root(&mut self, root: Option<PathBuf>) {
pub fn set_loose_addon_root(&mut self, root: Option<PathBuf>) {
if self.loose_root == root {
return;
}
@@ -726,7 +731,7 @@ mod tests {
/// (as [`sprite_candidates`] always hands it over) — the walk must land on the file anyway,
/// because the reference is a Windows client and addon authors never matched their own case.
#[test]
fn loose_sprite_file_maps_addon_paths_case_insensitively() {
fn loose_addon_file_maps_addon_paths_case_insensitively() {
let root =
std::env::temp_dir().join(format!("benilla-loose-sprite-test-{}", std::process::id()));
let dir = root.join("Atlas").join("Images").join("Maps");
@@ -736,7 +741,7 @@ mod tests {
// The returned SPELLING is filesystem-dependent (a case-insensitive filesystem answers
// the exact-join fast path with the candidate's own casing), so the claim is that the
// path opens the right file, not how it is spelt.
let hit = loose_sprite_file(
let hit = loose_addon_file(
&root,
"interface\\addons\\atlas\\images\\maps\\blackrockdepths.blp",
)
@@ -744,17 +749,17 @@ mod tests {
assert_eq!(std::fs::read(&hit).unwrap(), b"x");
// Not an AddOns path → not this store's question.
assert_eq!(loose_sprite_file(&root, "interface\\icons\\foo.blp"), None);
assert_eq!(loose_addon_file(&root, "interface\\icons\\foo.blp"), None);
// A directory is not a file, and a missing file is a miss, not an error.
assert_eq!(loose_sprite_file(&root, "interface\\addons\\atlas"), None);
assert_eq!(loose_addon_file(&root, "interface\\addons\\atlas"), None);
assert_eq!(
loose_sprite_file(&root, "interface\\addons\\atlas\\images\\maps\\nope.blp"),
loose_addon_file(&root, "interface\\addons\\atlas\\images\\maps\\nope.blp"),
None
);
// Dot-components never reach the filesystem (the read_under posture; `normalize_path`
// already forbids `/`, so this is the one lexical escape left to refuse).
assert_eq!(
loose_sprite_file(&root, "interface\\addons\\..\\..\\etc\\passwd"),
loose_addon_file(&root, "interface\\addons\\..\\..\\etc\\passwd"),
None
);
+2 -1
View File
@@ -2,6 +2,7 @@ use mlua::{ObjectLike, Table};
use crate::framexml::{self, Element};
use super::regions::FontAttrs;
use super::{abs_dim, children_named, Loader};
impl Loader<'_> {
@@ -200,7 +201,7 @@ impl Loader<'_> {
return;
}
};
self.apply_region_layout(tr, &region, self_name, dbg);
self.apply_region_layout(tr, &region, self_name, dbg, FontAttrs::Own);
}
pub(super) fn apply_size(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
+56 -10
View File
@@ -4,6 +4,42 @@ use crate::framexml::{self, Element};
use super::{abs_dim, abs_value, children_named, color_of, tex_coords_of, Loader};
/// Whether a region parses **its own** font attributes — the reference's `FONTSTRING+0x12c`,
/// which wow-re named `ownsFontAttrs` (`scratch/fontstring-loadxml-font-attrs.md` §3 and
/// `scratch/button-label-build-and-anchor-order.md`, both VERIFIED).
///
/// It exists for exactly one element in the whole schema. The ctor sets it to 1
/// (`0x770de7 mov byte [esi+0x12c],1`) and **one site image-wide clears it** — `0x778b7b`, inside
/// `CSimpleButton::LoadXML 0x7788c0`'s inline build `[0x778b4c, 0x778bb6)`, which is the
/// **`<NormalText>`** leg (tag `0x879978`, compared at `0x778b43`) and nothing else. A
/// `<ButtonText>` (tag `0x8799f0`, `0x7789c1`) is built by the ordinary `<FontString>` region
/// builder `0x6f2780` — the `<Layers>` walker's own — which never touches the flag, so an ordinary
/// label keeps every attribute it is written with.
///
/// Cleared, gate B (`0x7710d3` → `je 0x771468`) makes that string's own
/// `CSimpleFontString::LoadXML 0x770f40` skip `0x7710e1`–`0x771467` wholesale: `font=`,
/// `<FontHeight>`, `outline=`, `monochrome=`, the file load, `spacing=`, `justifyV=`, `justifyH=`,
/// `<Color>` and `<Shadow>`. (Gate A, `0x770f72`, additionally drops the `inherits=` registry-hit
/// live link.) The SAME node is fed instead to the button's persistent Normal-state `CSimpleFont`
/// at `+0x33c` (`0x778ba9`/`0x778baf call 0x783c30`) — one owner per attribute, no double
/// application. The word still reaches the label's paint and its `GetJustifyH` answer: that
/// loader's tail notify writes the resolved justify into the label's own `+0x120` (`0x784111` →
/// `0x784180` → `0x773530` → `0x770800` at `0x770876`), **after** the anchor is placed.
///
/// Only `justifyH`/`justifyV` are gated here because they are the only two of that surface this
/// pass ever applied: the rest live in `apply_fontstring_font`/`apply_region_visual`, which the
/// button's label pass does not call. Geometry — `<Size>`, `<Anchors>`, `setAllPoints`, `name=` —
/// is `CLayoutFrame::LoadXML`'s and is outside the gate on both legs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum FontAttrs {
/// The ctor's default: an ordinary region — a `<Layers>` `<FontString>`, an EditBox's special
/// string, a title region, **and a Button's `<ButtonText>`** — owns its font attributes.
Own,
/// A Button's `<NormalText>`, the one element the client disowns: its font attributes belong
/// to the button's Normal-state font, never to the label.
Disowned,
}
impl Loader<'_> {
/// `<Layers>`/`<Layer level=>`/`<Texture>`/`<FontString>` (rf24 `0x769d70`): create each region
/// via CreateTexture/CreateFontString on the draw layer, then apply its file/color/text.
@@ -62,7 +98,13 @@ impl Loader<'_> {
{
self.call_region(&region_wrapper, "SetAlpha", a, dbg);
}
self.apply_region_layout(region, &region_wrapper, parent_name, dbg);
self.apply_region_layout(
region,
&region_wrapper,
parent_name,
dbg,
FontAttrs::Own,
);
if is_fontstring {
// Font object (`inherits=`) first, then the FontString's own `font=`/
// `<FontHeight>`/`outline=` overrides — before the generic visual pass applies
@@ -154,7 +196,7 @@ impl Loader<'_> {
}
},
};
self.apply_region_layout(region, &region_wrapper, parent_name, dbg);
self.apply_region_layout(region, &region_wrapper, parent_name, dbg, FontAttrs::Own);
self.apply_fontstring_font(region, &region_wrapper, dbg);
self.apply_region_visual(region, &region_wrapper, false, dbg);
// The creation-path implicit anchor (decision 1310), as in `apply_layers`. For the
@@ -184,15 +226,17 @@ impl Loader<'_> {
/// A region's own geometry (decision 0068 v1): `<Size>` → SetWidth/SetHeight, `<Anchors>` →
/// SetPoint (owner-relative; `relativeTo` is `$parent`-substituted like a frame's), the
/// `setAllPoints="true"` shorthand, and the FontString `justifyH` attr → SetJustifyH. This is the
/// join that makes region `<Size>`/`<Anchors>` actually place the region (before, both were
/// silently dropped — the smeared-merchant root cause).
/// `setAllPoints="true"` shorthand, and — only for a region that [`FontAttrs::Own`]s them —
/// the FontString `justifyH`/`justifyV` attrs → SetJustifyH/V. This is the join that makes
/// region `<Size>`/`<Anchors>` actually place the region (before, both were silently dropped
/// — the smeared-merchant root cause).
pub(super) fn apply_region_layout(
&mut self,
region: &Element,
wrapper: &Table,
parent_name: &str,
dbg: &str,
font_attrs: FontAttrs,
) {
// ALL `<Size>` children in document order (same last-wins rule as `apply_size`: a templated
// region's own `<Size>` must overwrite its template's).
@@ -205,11 +249,13 @@ impl Loader<'_> {
self.call_region(wrapper, "SetHeight", h, dbg);
}
}
if let Some(j) = region.attr("justifyH") {
self.call_region(wrapper, "SetJustifyH", j.to_string(), dbg);
}
if let Some(j) = region.attr("justifyV") {
self.call_region(wrapper, "SetJustifyV", j.to_string(), dbg);
if font_attrs == FontAttrs::Own {
if let Some(j) = region.attr("justifyH") {
self.call_region(wrapper, "SetJustifyH", j.to_string(), dbg);
}
if let Some(j) = region.attr("justifyV") {
self.call_region(wrapper, "SetJustifyV", j.to_string(), dbg);
}
}
if region.attr_bool("setAllPoints") {
self.call_region(wrapper, "SetAllPoints", (), dbg);
+137
View File
@@ -1557,6 +1557,143 @@ mod loader_tests {
);
}
/// **The two spellings of a Button's label are two different legs, and only `<NormalText>` is
/// disowned of its own font attributes — so its `justifyH` never reaches the label and the
/// implicit anchor stays CENTER, while a `<ButtonText>`'s does and still seats LEFT.**
///
/// `CSimpleButton::LoadXML 0x7788c0`'s child chain routes them apart (wow-re
/// `scratch/button-label-build-and-anchor-order.md`, §5 + arbitration, VERIFIED):
/// `<ButtonText>` (tag `0x8799f0`, compared `0x7789c1`) goes to `0x7789d0 call 0x6f2780`, the
/// ordinary `<FontString>` region builder the `<Layers>` walker uses, which never writes
/// `+0x12c`; `<NormalText>` (tag `0x879978`, `0x778b43`) goes to the inline build
/// `[0x778b4c, 0x778bb6)` carrying `0x778b7b mov byte [edi+0x12c],0` — the one site image-wide
/// that clears the gate. Cleared, gate B (`0x7710d3 → je 0x771468`) makes the label's own
/// `CSimpleFontString::LoadXML 0x770f40` skip `0x7710e1`–`0x771467` wholesale: `font=`,
/// `<FontHeight>`, `outline=`, `monochrome=`, the file load, `spacing=`, **`justifyV=`,
/// `justifyH=`**, `<Color>` and `<Shadow>`. That node is fed instead to the button's
/// Normal-state `CSimpleFont` at `+0x33c` (`0x778ba9`/`0x778baf call 0x783c30`).
///
/// So the creation post-step `0x771480` reads the `<NormalText>` label's **untouched ctor
/// default** `0x212` = CENTER|MIDDLE (`0x770dd3`) and seats a single CENTER→CENTER anchor
/// (point 4, `0x7714dc`/`0x7714e4`). The authored `justifyH="LEFT"` still reaches the *paint*
/// and `GetJustifyH()` — `0x783c30`'s tail notify writes the resolved word into the label's own
/// `+0x120` (`0x784111` → `0x784180` → `0x773530` → `0x770800` at `0x770876`) — but that runs
/// downstream of the anchor, and a FontString pinned at one point is exactly as wide as its
/// text, so a left justify inside it has nothing to move.
///
/// The live case is Gatherer 1.0.0's minimap quick-menu, transcribed here: every row is a
/// `GathererUI_PopupButtonTemplate`, all rows are `SetWidth` to one common width and stacked
/// TOP-to-BOTTOM so they share a centreline, and the template's only alignment statement is
/// `<NormalText inherits=… justifyH="LEFT"/>`. Applying that word to the label seated all
/// seven rows LEFT→LEFT and the menu read as a left-aligned list against the reference's
/// centred one.
///
/// Two controls, and the second is why this test carries both spellings: an ordinary
/// `<Layers>` `<FontString justifyH="LEFT">` still seats LEFT
/// (`anchorless_fontstring_seats_at_its_justify_point` above), and so does a
/// `<ButtonText justifyH="LEFT"/>` — the gate is the `<NormalText>` leg's alone.
#[test]
fn a_button_label_element_does_not_own_its_justify() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Font name="GatherFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="10"/></FontHeight>
</Font>
<Button name="GathererUI_PopupButtonTemplate" virtual="true">
<Size><AbsDimension x="64" y="12"/></Size>
<NormalText inherits="GatherFont" justifyH="LEFT"/>
<HighlightText inherits="GatherFont" justifyH="LEFT"/>
<DisabledText inherits="GatherFont" justifyH="LEFT"/>
</Button>
<Button name="OwnLabelTemplate" virtual="true">
<Size><AbsDimension x="64" y="12"/></Size>
<ButtonText inherits="GatherFont" justifyH="LEFT"/>
</Button>
<Frame name="GathererUI_Popup">
<Size><AbsDimension x="120" y="60"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Frames>
<Button name="GathererUI_PopupButton1" inherits="GathererUI_PopupButtonTemplate" text="Minimap [on]">
<Anchors><Anchor point="TOP"/></Anchors>
</Button>
<Button name="OwnLabelButton" inherits="OwnLabelTemplate" text="Minimap [on]">
<Anchors><Anchor point="BOTTOM"/></Anchors>
</Button>
</Frames>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// 1 · The anchor — the whole mechanism, read straight off the label.
let (point, rel_point, x, y) = s
.eval::<(String, String, f64, f64)>(
"local p, rel, rp, x, y = GathererUI_PopupButton1:GetFontString():GetPoint(1) \
return p, rp, x, y",
)
.unwrap();
assert_eq!(
(point.as_str(), rel_point.as_str(), x, y),
("CENTER", "CENTER", 0.0, 0.0),
"<NormalText>'s justifyH is disowned, so 0x771480 seats the ctor's CENTER"
);
// 2 · ...and the authored word still reaches the label's PAINT and its getter, through the
// Normal-state font's link. Only the anchor was ever the reference's answer here.
assert_eq!(
s.eval::<String>("return GathererUI_PopupButton1:GetFontString():GetJustifyH()")
.unwrap(),
"LEFT",
"the state font's justify still reaches the label"
);
// 3 · The CONTROL, and the half the byte round corrected: `<ButtonText>` is built by the
// ordinary FontString builder (`0x6f2780`), which never clears `+0x12c`, so its own
// justifyH lands on the label and the same post-step seats it LEFT.
let (point, rel_point) = s
.eval::<(String, String)>(
"local p, rel, rp = OwnLabelButton:GetFontString():GetPoint(1) return p, rp",
)
.unwrap();
assert_eq!(
(point.as_str(), rel_point.as_str()),
("LEFT", "LEFT"),
"<ButtonText> owns its font attributes — the gate is the <NormalText> leg's alone"
);
// 4 · The visible consequence, in resolved coordinates. The popup is [0,0]..[120,60] and
// the rows are 64 wide, centred on x=60 → [28,92]. A 40-wide label centred in that is
// [40,80]; seated LEFT it is [28,68] — the left edge every Gatherer row shared before this.
s.run(
"GathererUI_PopupButton1:GetFontString():SetWidth(40) \
OwnLabelButton:GetFontString():SetWidth(40)",
)
.unwrap();
s.resolve();
let (cl, cr, ll, lr) = s
.eval::<(f64, f64, f64, f64)>(
"local c = GathererUI_PopupButton1:GetFontString() \
local l = OwnLabelButton:GetFontString() \
return c:GetLeft(), c:GetRight(), l:GetLeft(), l:GetRight()",
)
.unwrap();
assert_eq!(
(cl, cr),
(40.0, 80.0),
"the Gatherer row's label is centred on the button, not hugging its left edge"
);
assert_eq!(
(ll, lr),
(28.0, 68.0),
"the <ButtonText> row's label still hugs the button's left edge"
);
}
/// **`text=` is a GLOBAL-STRING LOOKUP, not a literal** (wow-re rf28 l.36/l.115 →
/// `FrameScript_GetText 0x703bf0`). Every arm of [`Loader::resolve_text`] in one document:
/// a `<Button text=>`, a `<ButtonText text=>` and a `<FontString text=>` all resolve through
+57 -19
View File
@@ -3,6 +3,7 @@ use mlua::{ObjectLike, Table};
use crate::framexml::{self, Element};
use crate::script::LabelFont;
use super::regions::FontAttrs;
use super::{
abs_dim, abs_value, children_named, children_named_any, color_of, tex_coords_of, Loader,
};
@@ -137,7 +138,7 @@ impl Loader<'_> {
// SetPoint over a live implicit corner would otherwise leave the OTHER corner
// standing (the slot law) and weld an anchored state texture to the button.
this.call_region(&region, "ClearAllPoints", (), dbg);
this.apply_region_layout(t, &region, self_name, dbg);
this.apply_region_layout(t, &region, self_name, dbg, FontAttrs::Own);
if let Err(e) = crate::script::implicit_creation_anchor_lua(this.lua, &region) {
this.report
.errors
@@ -176,16 +177,26 @@ impl Loader<'_> {
// **Both spellings of the label** (`ButtonText` | `NormalText`), in document order.
//
// `<NormalText>` is not a synonym bolted on here — it is the binary's own second name for
// this slot, and it does strictly more: `CSimpleButton::LoadXML 0x7788c0` constructs the
// label `CSimpleFontString` on the `<NormalText>` leg (`0x778b6c call 0x770d30`), clears
// its `ownsFontAttrs` (`0x778b7b` — the ONE site image-wide that clears it), hands it to
// the shared adopter `0x778d20`, and runs its `LoadXML 0x770f40` with the whole
// font-attribute surface skipped, because the SAME node is fed separately to the button's
// persistent Normal-state `CSimpleFont` at `+0x33c` (`0x778ba9`/`0x778baf call 0x783c30`).
// One owner per attribute, no double application — which is why this pass reads the
// element's geometry and name and the state-font pass below reads its `inherits=`/`font=`,
// and neither reads the other's. (wow-re `scratch/fontstring-loadxml-font-attrs.md` §3/§7,
// `scratch/resize-bounds-and-button-fontstring.md` §5.3 — all VERIFIED off the bytes.)
// this slot, and **it is a genuinely different leg** (wow-re
// `scratch/button-label-build-and-anchor-order.md`, §5 + arbitration, VERIFIED; it
// corrected the earlier reading this comment used to carry). `CSimpleButton::LoadXML
// 0x7788c0`'s 15-comparison child chain routes the two label spellings apart:
//
// - `<ButtonText>` (tag `0x8799f0`, compared `0x7789c1`) → `0x7789d0 call 0x6f2780`, the
// **ordinary `<FontString>` region builder** — whose only other caller is the `<Layers>`
// walker (`0x769e61`). It never writes `+0x12c`, so the ctor's `1` stands, both gates in
// `0x770f40` stay open, and the element's `justifyH`/`justifyV` (and `inherits=`,
// `font=`, `<Color>`, `<Shadow>`) apply to the LABEL, exactly like any other FontString.
// - `<NormalText>` (tag `0x879978`, compared `0x778b43`) → the inline build
// `[0x778b4c, 0x778bb6)`, a hand-rolled copy of that builder carrying
// `0x778b7b mov byte [edi+0x12c],0` — the ONE site image-wide that clears the gate. Its
// font attributes are skipped on the label and fed to the button's persistent
// Normal-state `CSimpleFont` at `+0x33c` (`0x778ba9`/`0x778baf call 0x783c30`) instead.
// - `<HighlightText>`/`<DisabledText>` build no region at all: pure aliases for the
// Highlight/Disabled fonts (`0x778bf4`), which is why they are not in this loop.
//
// So both spellings make the label and take its geometry and name, and only `<NormalText>`
// hands its font attributes away — [`FontAttrs`] below is that one difference.
//
// The reference FrameXML writes only `<ButtonText>` (31 sites, 16 of them
// `name="$parentText"`) and never `<NormalText>`, so nothing we ship could notice the
@@ -194,9 +205,17 @@ impl Loader<'_> {
// name="$parentText" …>` left `KLHTM_SelfFrameHeaderNameText` nil, and the addon died in
// its `PLAYER_LOGIN` handler on exactly that global.
for bt in children_named_any(el, &["ButtonText", "NormalText"]) {
// Which leg built this label decides who owns its font attributes — see the chain
// above. `<ButtonText>` goes through the ordinary FontString builder and keeps them;
// `<NormalText>` is the one the button disowns (`0x778b7b`).
let font_attrs = if bt.tag.eq_ignore_ascii_case("NormalText") {
FontAttrs::Disowned
} else {
FontAttrs::Own
};
// Create the label slot even with no text yet (the geometry below must land on a real
// region; SetText is the slot's lazy constructor), then apply the element's own
// `<Size>/<Anchors>`/justify to it — the ref anchors ButtonText all over (the quest
// `<Size>`/`<Anchors>` to it — the ref anchors ButtonText all over (the quest
// greeting rows hang theirs at TOPLEFT+20 beside the bullet; without this every
// labelled Button centered its text over the whole face).
// `<ButtonText text=>` is a FontString's own attribute — the same global-string lookup
@@ -228,12 +247,31 @@ impl Loader<'_> {
self.call(wrapper, "SetText", label, dbg);
if let Ok(region) = wrapper.call_method::<Table>("GetFontString", ()) {
// Same clear→layout→implicit order as the state textures above (decision 1310):
// SetText materialized the label with the runtime path's implicit CENTER anchor;
// the XML path re-derives it AFTER the element's own `<Anchors>`/justify apply
// (so a `<ButtonText justifyH="LEFT">` with no anchors seats LEFT, as the real
// FontString post-step `0x771480` would).
// SetText materialized the label with the runtime path's implicit anchor; the XML
// path re-derives it AFTER the element's own `<Anchors>` apply, which is where
// both real legs run it (`0x6f27f5` for `<ButtonText>`, `0x778b96` for
// `<NormalText>` — two of that post-step's three call sites).
//
// **`font_attrs` is the load-bearing argument here** (decision 2100). On the
// `<NormalText>` leg the element's `justifyH`/`justifyV` belong to the button's
// per-state font, never to the label, so the post-step reads the string's
// untouched ctor word (`0x212` = CENTER|MIDDLE) and seats CENTER. Applying the
// word to the label instead seated Gatherer's whole quick-menu LEFT→LEFT against
// the reference's centred rows: its `GathererUI_PopupButtonTemplate` states its
// alignment once, as `<NormalText inherits="GameFontNormal" justifyH="LEFT"/>`,
// and every row is `SetWidth` to one common width, so a left-seated label puts all
// seven texts on one left edge. The word still reaches the *paint* and
// `GetJustifyH()` — `0x783c30`'s tail notify writes the resolved justify into the
// label's own `+0x120` (`0x784111` → `0x784180` → `0x773530` → `0x770800` at
// `0x770876`), downstream of the anchor — which is why the visible difference is
// the anchor alone.
//
// The adopter's own conditional anchor (`0x778d20`, `[button+0x390]` — decision
// 1996) is **structurally dead on both XML label paths**: the builder anchors
// first, and the 9-slot scan at `0x778d5f` then finds a slot filled. It is live
// only where the label is born from `text=` or Lua `SetText`.
self.call_region(&region, "ClearAllPoints", (), dbg);
self.apply_region_layout(bt, &region, self_name, dbg);
self.apply_region_layout(bt, &region, self_name, dbg, font_attrs);
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, &region) {
self.report
.errors
@@ -560,7 +598,7 @@ impl Loader<'_> {
if let Some(mode) = tt.attr("alphaMode") {
self.call_region(&region, "SetBlendMode", mode.to_string(), dbg);
}
self.apply_region_layout(tt, &region, self_name, dbg);
self.apply_region_layout(tt, &region, self_name, dbg, FontAttrs::Own);
if let Some(tc) = tex_coords_of(tt) {
self.call_region(&region, "SetTexCoord", tc, dbg);
}
@@ -634,7 +672,7 @@ impl Loader<'_> {
if let Some(mode) = tt.attr("alphaMode") {
self.call_region(&region, "SetBlendMode", mode.to_string(), dbg);
}
self.apply_region_layout(tt, &region, self_name, dbg);
self.apply_region_layout(tt, &region, self_name, dbg, FontAttrs::Own);
if let Some(tc) = tex_coords_of(tt) {
self.call_region(&region, "SetTexCoord", tc, dbg);
}
+38 -2
View File
@@ -587,7 +587,15 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String]) {
for file in files {
let path = crate::loader::join_ref(name, file);
let Some(bytes) = read(&path) else {
log_error(lua, &format!("{name}/{file}: not found"));
// **A manifest entry the package does not ship is the PACKAGE's defect** — 1450's
// rule, and until 2107 this path had its own copy of it that said the opposite.
// The reference logs `Couldn't open %s` and carries on
// (`diagnostics::record_load_failure`'s doc has the bytes), so the addon still loads
// and `IsAddOnLoaded` still answers 1; the miss is retained and warned, never a
// script error. It cost 61 corpus addons their session-start row: FuBar's
// `LoadLoadOnDemandPlugins` demand-loads 55 plugins whose `.toc`s list a koKR
// localization file none of them ships.
load_miss(lua, name, file);
continue;
};
if std::path::Path::new(file)
@@ -602,7 +610,10 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String]) {
let doc = match crate::framexml::parse(&crate::source::decode(&bytes)) {
Ok(d) => d,
Err(e) => {
log_error(lua, &format!("{name}/{file}: {e}"));
// The walk's severity for the same thing: a document that will not parse is a
// load failure, not a raise — the reference answers it with a `FrameXML.log` line
// and silence (`Couldn't parse XML in %s` `0x846fd8`, sink severity 2).
load_failure(lua, &format!("{name}/{file}: {e}"));
continue;
}
};
@@ -613,6 +624,31 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String]) {
}
}
/// A `.toc` line naming a file the package does not contain, classified the one way
/// ([`crate::script::diagnostics::record_load_failure`]) and logged the one severity (1450: a
/// player's addon is never an ERROR of ours).
///
/// Both halves are the startup walk's, verbatim in effect: the walk pairs its `warn!` with
/// `report_load_failure`, and this pairs the host warning channel — which the app drains at
/// `warn!` — with the same retention call. What it deliberately does **not** touch is
/// [`Model::errors`], the instruments' script-error channel: nothing raised.
fn load_miss(lua: &Lua, name: &str, file: &str) {
load_failure(lua, &format!("{name}/{file}: not found"));
}
/// Record + say out loud, the pair [`log_error`]'s louder sibling makes for a failure that
/// **raised**. `benilla-ui` has no logger of its own, so the second half goes on the host's
/// non-fatal warning channel, which the app drains at `warn!` — the walk's own severity for the
/// same failure.
fn load_failure(lua: &Lua, msg: &str) {
let msg = format!("LoadAddOn: {msg}");
crate::script::diagnostics::record_load_failure(lua, &msg);
lua.app_data_mut::<Model>()
.expect("model")
.warnings
.push(msg);
}
/// `root/rel`, refusing to escape `root` — the AddOns-root sandbox (1186), lexical and applied
/// before any filesystem call. `join_ref` has already resolved the path, so an escape survives as
/// a leading `..` and is what the `Normal`-component test rejects.
+24 -3
View File
@@ -210,12 +210,33 @@ impl super::UiScript {
///
/// The caller still logs its own line; this is the retention half, not a replacement for it.
pub fn report_load_failure(&self, msg: &str) {
self.model_mut()
.diagnostics
.record(DiagnosticKind::Load, msg);
record_load_failure(&self.lua, msg);
}
}
/// **THE rule for "an addon file did not load"** — one implementation, both loaders (decision
/// 2107).
///
/// The startup walk reaches it through [`super::UiScript::report_load_failure`]; `LoadAddOn`'s
/// demand load reaches it directly, because it runs inside a Lua binding and holds only `&Lua`
/// (1191 §4). That was the whole defect 2107 fixes: the two paths had **two copies** of the rule
/// and the demand-load copy classified a missing manifest entry as a script error, so an addon the
/// reference loads (logging `Couldn't open %s` and continuing) came back as a session failure here.
///
/// The reference's own shape, VERIFIED (wow-re `system/ui/scratch/xml-toc-path-resolution.md` §4
/// and `include-lua-dispatch.md` §7): a failed open is **non-fatal** — `0x6edaa0` logs
/// `"Couldn't open %s"` (`0x846ff4`) to the log sink at severity 2 and returns null; every failure
/// leg "reports through the sink and returns normally, with no throw, no `longjmp` and no abort",
/// and the enclosing `.toc`/document keeps loading. Nothing on that path reaches the Lua error
/// handler, which is why this never dispatches (1495) and why 1450 made it a WARN: a broken
/// package is the *package's* defect, and ERROR means "the client is broken".
pub(crate) fn record_load_failure(lua: &Lua, msg: &str) {
lua.app_data_mut::<Model>()
.expect("model app_data set")
.diagnostics
.record(DiagnosticKind::Load, msg);
}
/// Register the error-log reads the `BenillaScriptLogFrame` polls.
///
/// **`Benilla`-prefixed because they are ours**, not 1.12's — the prefix rule as
+7 -1
View File
@@ -242,10 +242,16 @@ pub(super) fn install(
let (path, height) = set_font_args(&file, &height, widget)?;
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// The verdict is the host's when there is a host: nil is a *load* failure
// (`0x5c1ae0` under the `0x44d040` font-factory cache), so a path naming no file
// — an addon's TTF the AddOns folder does not hold — has to come back falsey, and
// only the store knows. With no probe installed, a non-empty path is 1: a VM with
// no font backend has nothing for a load to fail against (decision 2103).
let ok =
!path.is_empty() && model.font_probe.as_ref().is_none_or(|probe| probe(&path));
let d = model.region_data.entry(rh).or_default();
// Every argument supplied is an EXPLICIT set: it must survive a later mutation of
// the font object this region inherits.
let ok = !path.is_empty();
if ok {
d.font_path = Some(path);
d.font_explicit.face = true;
+112 -68
View File
@@ -112,57 +112,18 @@ const REMOVED: &[(&str, &[&str])] = &[
("math", &["fmod", "modf", "huge", "cosh", "sinh", "tanh"]),
];
/// Lua 5.0's `luaL_getn`/`luaL_setn`, transcribed — **including the private `sizes` table**, which
/// is the part that matters and the part a plain reading of the manual leaves out.
///
/// 5.0 does **not** simply write `t.n`. `luaL_setn` writes `t.n` *only if a numeric `t.n` already
/// exists*; otherwise it stores the size in a weak-keyed table kept in the registry, so the size
/// is invisible to anything walking the table. `luaL_getn` reads `t.n`, then `sizes[t]`, then
/// falls back to counting.
///
/// **The first draft of this module wrote `t.n` unconditionally, and the corpus caught it inside
/// an hour.** `AceOO-2.0`'s `_Embed` walks its mixin's exports with `next(state.export, field)`
/// and errors on any field the target already has; a spurious `n` made that
/// `Method conflict in attempt to mixin. Field "n"` — **39 addons**, a *bigger* wall than the
/// `setn` gap it was meant to fix. Writing "faithful to Lua 5.0" in a doc comment does not make a
/// derivation verified, and an addon iterating a table is a sharper instrument than a memory of
/// `lauxlib.c`.
const LUA_5_0_TABLE_SIZE: &str = r#"
do
-- The registry-side weak table. Weak KEYS: a table nobody else holds must still be
-- collectable, and 5.0's own `getsizes` uses exactly this metatable.
local sizes = setmetatable({}, { __mode = "k" })
function table.setn(t, n)
if type(rawget(t, "n")) == "number" then
rawset(t, "n", n) -- an existing numeric `n` field IS the size; keep using it
else
sizes[t] = n -- otherwise the size lives out of sight
end
end
function table.getn(t)
local n = rawget(t, "n")
if type(n) == "number" then return n end
n = sizes[t]
if type(n) == "number" then return n end
return #t -- neither: the array border, which is 5.1's whole answer
end
end
"#;
/// Lua 5.0's remembered table size, and the whole `n`-based table library that rides it
/// (decision 2102) — [`table_size`]'s own header is the mechanism, the byte census behind it, and
/// the runtime failure that retired this module's "revisit if ever traced" note.
mod table_size;
/// Install the dialect. Runs **before** the WoW stdlib layer, so its aliases bind the 5.0-shaped
/// functions rather than the 5.1 ones they replace.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// ── 1 · `setn`/`getn` get their 5.0 meaning back ──────────────────────────────────────────
// Written in Lua because the weak-keyed side table below is the whole mechanism and a Lua
// `setmetatable({}, {__mode="k"})` says that in one line.
lua.load(LUA_5_0_TABLE_SIZE)
.set_name("=[benilla lua5.0 dialect]")
.set_mode(mlua::ChunkMode::Text)
.exec()?;
// ── 1 · the `n`-based table library gets its 5.0 meaning back ─────────────────────────────
table_size::install(lua)?;
// ── 2 · the 5.1-only members go ───────────────────────────────────────────────────────────
for (lib, names) in REMOVED {
@@ -469,39 +430,122 @@ mod tests {
assert_eq!(s.eval::<f64>("return __pow(2, 10)").unwrap(), 1024.0);
}
/// **The corpus's other dialect probe, and we answer it wrong** (decision 1208).
/// **The corpus's other dialect probe, answered the way 1.12's parser answers it** (2101,
/// closing 1208).
///
/// Ace2 asks "am I on Lua 5.1?" by compiling a 5.1-only construct — the vararg *expression*,
/// which 5.0 has no grammar for (5.0 collects varargs into `arg`; the shipped 1.12 FrameXML
/// reads `arg`/`arg.n` 64 times across 177 files and never once writes `...` as a value):
///
/// ```lua
/// if loadstring("return function(...) return ... end") and AceLibrary:HasInstance(MAJOR)
/// then return end -- lua51 check
/// local lua51 = loadstring("return function(...) return ... end") and true or false
/// ```
///
/// On the real 1.12 client that `loadstring` returns nil, the guard never fires, and normal
/// revision comparison decides which copy of a library wins. On ours it compiles, so the
/// **newer** copy silently declines to register and whichever copy loaded first is pinned
/// forever. **92 library files across 24 corpus addon folders carry it.**
/// 170 `loadstring` sites across the 219-addon corpus are that one question. Answering it
/// `true` puts every Ace-embedding addon on its client-2.0 branch — which is how Cartographer
/// came to hook `CloseSpecialWindows`, a name 1.12's `_G` does not have.
///
/// Asserted rather than fixed: unlike `table.setn` or `bit`, this is not reachable from inside
/// Lua — it is the parser. 1208 records the measurement; 1202 is where the fix lives.
/// The fix is the parser, not a library: `simpleexp` (client `0x6fd240`) has no `TK_DOTS`
/// arm, so `...` as a value falls to `prefixexp` and raises. The exact probe line is asserted
/// here in the shape the addons write it.
#[test]
fn the_vararg_expression_compiles_and_that_is_the_1208_divergence() {
fn the_vararg_expression_is_a_syntax_error_as_it_is_on_1_12() {
let s = UiScript::new().unwrap();
assert!(
s.eval::<bool>(r#"return loadstring("return function(...) return ... end") ~= nil"#)
.unwrap(),
"if this ever fails, the VM became 5.0-shaped and 1208 closed with 1202"
s.eval::<bool>(
r#"return (loadstring("return function(...) return ... end") and true or false) == false"#
)
.unwrap(),
"the Ace2 lua51 probe must answer false, as it does on the 1.12 client"
);
// The 5.0 half of the same question, which we answer correctly (1194): `arg` still exists.
// ...and it fails the way 5.0 fails it: `simpleexp`'s default arm -> `prefixexp`, whose
// head accepts only `(` or a NAME. Same reject site, same words, as a stray `;`.
let msg: String = s
.eval(r#"local f, e = loadstring("return ...") return tostring(e)"#)
.unwrap();
assert!(
msg.contains("unexpected symbol"),
"5.0's own message for this, not a bespoke one: {msg}"
);
// The 5.0 half of the same question, which we already answered correctly (1194): `arg`.
assert_eq!(
s.eval::<i64>("local f = function(...) return arg.n end return f(1, 2, 3)")
.unwrap(),
3,
"`arg` is 5.1's compat-vararg table, which is the form all of 1.12 FrameXML uses"
);
// And now EVERY vararg function gets one. 5.1 cleared VARARG_NEEDSARG for any function
// that mentioned `...`; with the arm deleted nothing clears it, which is 5.0's rule.
assert_eq!(
s.eval::<i64>("local f = function(a, ...) return arg.n end return f(1, 2, 3)")
.unwrap(),
2
);
// A `...` in a PARAMETER LIST is 5.0's own grammar and must still parse — the deletion is
// `simpleexp`'s arm, not `parlist`'s.
assert!(s
.eval::<bool>(r#"return loadstring("return function(...) return arg.n end") ~= nil"#)
.unwrap());
}
/// **`#` and `%` are not in 1.12's grammar either** (2101) — the same class, the same
/// byte evidence, gated in the same hunk-set.
///
/// `getunopr` (`0x6fe0a0`) is a leaf testing exactly two tokens, `-` and `not`, so its
/// `OPR_NOUNOPR` is 2 — a three-member enum, 5.0's, not 5.1's four-member one with
/// `OPR_LEN`. `getbinopr` (`0x6fe0c0`) bases its switch at `'*'` (0x2A); `%` is 0x25, below
/// the range, and reaches `OPR_NOBINOPR` = 14 — a fifteen-member `BinOpr`, again 5.0's. The
/// metamethod-name pool at `0x871896` has neither `__len` nor `__mod`, which is where
/// wow-5875-re saw it first (`system/ui/scratch/lua-dialect.md` §1).
#[test]
fn the_length_and_modulo_operators_are_not_in_the_grammar() {
let s = UiScript::new().unwrap();
for probe in ["return #t", "return 7 % 3", "local n = #({1,2}) return n"] {
assert!(
s.eval::<bool>(&format!("return loadstring({:?}) == nil", probe))
.unwrap(),
"{probe} is 5.1-only syntax; 1.12's parser rejects it"
);
}
// The 5.0 spellings of both, which the reference has and every 1.12-era addon uses.
assert_eq!(s.eval::<i64>("return table.getn({1,2,3})").unwrap(), 3);
assert_eq!(s.eval::<i64>("return getn({1,2,3})").unwrap(), 3);
assert_eq!(s.eval::<f64>("return math.mod(7, 3)").unwrap(), 1.0);
assert_eq!(s.eval::<i64>("return string.len('abcd')").unwrap(), 4);
}
/// **The shapes the reference's own files are written in still load** — the other half of the
/// gate, and the one that would make it a regression if it ever stopped being true.
///
/// Measured, not assumed: the three constructs above appear **zero** times in the extracted
/// 1.12 FrameXML (177 files), GlueXML, Blizzard's own addons, and the director's installed
/// AddOns folder. This chunk is the 5.0 grammar those files actually use, including the three
/// 5.0-isms the fork restores (1215's iterator-less generic-for, `LUA_COMPAT_LSTR`'s nesting
/// long strings, 1315's constructor semicolon).
#[test]
fn the_5_0_grammar_the_reference_writes_still_parses() {
let s = UiScript::new().unwrap();
s.run(
r#"
-- 5.0 varargs: the implicit `arg` table, the form all of 1.12 FrameXML uses.
local function count(...) local n = 0 for i = 1, arg.n do n = n + arg[i] end return n end
-- the iterator-less generic-for (1215)
local sum = 0
for k, v in { 3, 4 } do sum = sum + v end
-- a table constructor with 5.0's compat semicolon (1315)
local t = { a = 1; b = 2; }
-- nesting long strings (LUA_COMPAT_LSTR = 2)
local s2 = [[outer [[inner]] outer]]
-- 5.0's own spellings for what `#`/`%` would say
BENILLA_GRAMMAR_OK = count(1, 2, 3) + sum + t.a + t.b
+ table.getn({ 1, 2 }) + math.mod(7, 3) + string.len(s2)
"#,
)
.expect("the reference's own grammar must still load");
assert_eq!(
s.eval::<i64>("return BENILLA_GRAMMAR_OK").unwrap(),
6 + 7 + 3 + 2 + 1 + 21
);
}
/// **The six `debug*` stubs are the REFERENCE's no-ops, not ours** — and that distinction is
@@ -538,19 +582,19 @@ mod tests {
assert!(ms >= 0.0, "elapsed milliseconds, not nil: {ms}");
}
/// The known divergence, pinned so it is a decision rather than a surprise.
///
/// In Lua 5.0 `table.insert` consults `getn`, so this would land at index 1. Here it lands at
/// `#t + 1`. Recorded in the module doc with the corpus measurement that justifies it; if this
/// test ever needs to change, the trade has been revisited on purpose.
/// The divergence that USED to be pinned here is gone (decision 2102): `table.insert` now
/// consults the remembered size, as `0x7fb6d8` does. The behaviour it asserted — an append
/// landing at the border after `setn(t, 0)` — was traced to a real addon failure, so the
/// mechanism moved to [`super::table_size`] and its tests moved with it. This is the one line
/// that used to say otherwise, kept as the shape a reader will look for.
#[test]
fn table_insert_still_uses_the_border_not_setn() {
fn table_insert_consults_the_remembered_size() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("local t = {1,2,3} table.setn(t, 0) table.insert(t, 9) return #t")
s.eval::<i64>("local t = {1,2,3} table.setn(t, 0) table.insert(t, 9) return t[1]")
.unwrap(),
4,
"5.1's insert appends at the border; 5.0's would have written index 1"
9,
"5.0's insert writes index getn+1, which after setn(t, 0) is index 1"
);
}
}
@@ -0,0 +1,505 @@
//! **Lua 5.0's remembered table size, and the whole library that rides it** (decision 2102).
//!
//! 5.0 does **not** simply write `t.n`. `luaL_setn` (`0x6f4ea0`) writes `t.n` *only if a
//! non-negative numeric `t.n` already exists*; otherwise it stores the size in a weak-keyed table
//! kept in the registry (`getsizes 0x6f4fc0`, registry slot 2), so the size is invisible to
//! anything walking the table. `luaL_getn` (`0x6f5050`) reads `t.n`, then `sizes[t]`, then counts
//! `rawgeti` from 1 until the first nil.
//!
//! **And the size is not private to `getn`/`setn` — the whole table library rides it.** wow-re's
//! exhaustive rel32 census (`system/ui/scratch/lua-dialect.md` §4) lists every call site:
//! `luaL_getn` is called by `table.getn`, `table.insert`, `table.remove`, `table.concat`,
//! `table.sort`, `table.foreachi` and **base `unpack`**; `luaL_setn` by `table.setn`,
//! `table.insert` and `table.remove`. So `insert`/`remove` **update** the remembered size and the
//! six readers read it — this library is `n`-based end to end, and a `#`-based (5.1) one is a
//! different observable.
//!
//! **This module shipped for months with only `getn`/`setn` converted, and the divergence was
//! recorded as knowingly-accepted "revisit if a runtime failure is ever traced here". One was.**
//! Mik's Scrolling Battle Text 4.43 displays nothing at all in combat, because every combat line
//! it parses runs through
//!
//! ```lua
//! MikCEH.EraseTable(unorderedCaptureData) -- wipe, then table.setn(t, 0)
//! … table.insert(unorderedCaptureData, capture) … -- 5.1: lands at #t+1, size NOT updated
//! if (table.getn(tempCapturedData) ~= 0) then -- reads the stale 0 → "no match"
//! ```
//!
//! The captures were *there* (`t[1] = "Kobold Vermin"`, `t[2] = "12"`) and `getn` answered 0, so
//! every parser returned nil and the addon drew nothing while scoring a clean load. That is the
//! exact shape the note warned about, and it is why the readers are converted here too rather than
//! left half-way: a library where the mutators maintain one size and the readers consult another
//! is a worse place to stand than a uniformly wrong one.
//!
//! **The first draft of the `getn`/`setn` half wrote `t.n` unconditionally, and the corpus caught
//! it inside an hour.** `AceOO-2.0`'s `_Embed` walks its mixin's exports with
//! `next(state.export, field)` and errors on any field the target already has; a spurious `n` made
//! that `Method conflict in attempt to mixin. Field "n"` — **39 addons**, a *bigger* wall than the
//! `setn` gap it was meant to fix. The invisibility of the side store is load-bearing, and its
//! test is below.
use mlua::{Function, Lua, MultiValue, Table, Value};
/// Where the weak-keyed side store lives. 5.0 keeps it in registry slot 2; mlua's registry is
/// its own, so it gets a name instead — the property that matters is that it is reachable from
/// nothing an addon can see.
const SIZES: &str = "benilla.lua50.sizes";
/// `checkint 0x6f4f80` as `luaL_getn`/`luaL_setn` use it: a stored size counts only when it is
/// a number **and** non-negative — a negative or absent one selects the next leg. (The
/// reference coerces a numeric *string* here too, via `lua_tonumber`; we do not, and no corpus
/// caller writes a string `n`.)
fn as_size(v: &Value) -> Option<i64> {
match v {
Value::Integer(i) => (*i >= 0).then_some(*i),
#[allow(clippy::cast_possible_truncation)]
Value::Number(n) => {
let i = *n as i64;
(i >= 0).then_some(i)
}
_ => None,
}
}
/// The bound on how many elements one `insert`/`remove`/`unpack` may shift or push.
///
/// **Not the reference's** — 5.0 has no such limit, and this is the third knowing divergence in
/// this module. It exists because a remembered size is an arbitrary number an addon wrote: after
/// `table.setn(t, 2000000000)`, `table.remove(t, 1)` shifts two billion slots, and this runs in
/// Rust, where the VM's instruction budget cannot interrupt it — a hung client rather than a slow
/// one. A UI table never comes near a million entries; a runaway always exceeds it.
const MAX_SHIFT: i64 = 1_000_000;
/// `luaL_getn 0x6f5050` — `t.n`, then `sizes[t]`, then a linear count.
///
/// The count is `rawgeti` from 1 to the first nil, not the `#` border: 1.12's Lua has no length
/// operator at all (decision 2101) and the two disagree on a table with holes, where `#` is
/// explicitly undefined. It costs O(n) — exactly as it does in the reference, and only until
/// the first `insert`/`setn` remembers a size for that table.
fn get_n(sizes: &Table, t: &Table) -> mlua::Result<i64> {
if let Some(n) = as_size(&t.raw_get::<Value>("n")?) {
return Ok(n);
}
if let Some(n) = as_size(&sizes.raw_get::<Value>(t.clone())?) {
return Ok(n);
}
let mut i = 1i64;
while !matches!(t.raw_get::<Value>(i)?, Value::Nil) {
i += 1;
}
Ok(i - 1)
}
/// `luaL_setn 0x6f4ea0` — into `t.n` when the table already carries one, else out of sight.
fn set_n(sizes: &Table, t: &Table, n: i64) -> mlua::Result<()> {
if as_size(&t.raw_get::<Value>("n")?).is_some() {
t.raw_set("n", n)
} else {
sizes.raw_set(t.clone(), n)
}
}
/// Install the `n`-based table library over mlua's 5.1 one.
///
/// `concat` and `sort` **wrap** the C implementations rather than re-writing them: the only
/// thing 5.0 changes about either is which number bounds the walk, and the C bodies carry the
/// error messages (`invalid value (at index %d) in table for 'concat'`, `invalid order
/// function for sorting`) and the sort algorithm that a Rust re-write would have to reproduce —
/// including *not* handing a Lua comparator to Rust's `sort_by`, which panics on an
/// inconsistent one.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
let store = lua.create_table()?;
let meta = lua.create_table()?;
// Weak KEYS: a table nobody else holds must still be collectable, and 5.0's own `getsizes`
// builds exactly this metatable.
meta.set("__mode", "k")?;
store.set_metatable(Some(meta))?;
// Kept in the registry so it survives for the VM's life and nothing an addon can reach
// names it — and captured by every closure below, so the hottest function in the whole UI
// (`table.insert`) costs two raw gets rather than a registry walk per call.
lua.set_named_registry_value(SIZES, store.clone())?;
let sizes = store;
let t: Table = g.get("table")?;
let getn_sizes = sizes.clone();
t.set(
"getn",
lua.create_function(move |_, t: Table| get_n(&getn_sizes, &t))?,
)?;
// `table.setn 0x7fb670` — 0 results.
let setn_sizes = sizes.clone();
t.set(
"setn",
lua.create_function(move |_, (t, n): (Table, i64)| set_n(&setn_sizes, &t, n))?,
)?;
// `table.insert 0x7fb6a0` — `n = getn(t) + 1`; with a position argument the position may
// GROW n past it; `setn` first, then shift up from n down to pos, then store.
let insert_sizes = sizes.clone();
t.set(
"insert",
lua.create_function(move |_, args: MultiValue| {
let argc = args.len();
let mut it = args.into_iter();
let Some(Value::Table(t)) = it.next() else {
return Err(mlua::Error::runtime(
"bad argument #1 to 'insert' (table expected)",
));
};
let mut n = get_n(&insert_sizes, &t)? + 1;
// The 2-vs-3 argument split is `lua_gettop`, so an explicit trailing nil counts:
// `insert(t, 4, nil)` writes nil at 4, it does not append the 4.
let (pos, v) = if argc <= 2 {
(n, it.next().unwrap_or(Value::Nil))
} else {
let pos = match it.next() {
Some(Value::Integer(i)) => i,
#[allow(clippy::cast_possible_truncation)]
Some(Value::Number(x)) => x as i64,
_ => {
return Err(mlua::Error::runtime(
"bad argument #2 to 'insert' (number expected)",
))
}
};
if pos > n {
n = pos;
}
(pos, it.next().unwrap_or(Value::Nil))
};
if n.saturating_sub(pos) > MAX_SHIFT {
return Err(mlua::Error::runtime("table too big to insert into"));
}
set_n(&insert_sizes, &t, n)?;
while n > pos {
let above: Value = t.raw_get(n - 1)?;
t.raw_set(n, above)?;
n -= 1;
}
t.raw_set(pos, v)
})?,
)?;
// `table.remove 0x7fb750` — an empty table (remembered size <= 0) returns NO value and
// touches nothing; otherwise `setn(n-1)`, take `t[pos]`, close the gap, nil the tail.
let remove_sizes = sizes.clone();
t.set(
"remove",
lua.create_function(move |_, (t, pos): (Table, Option<i64>)| {
let n = get_n(&remove_sizes, &t)?;
let pos = pos.unwrap_or(n);
if n <= 0 {
return Ok(MultiValue::new());
}
if n.saturating_sub(pos) > MAX_SHIFT {
return Err(mlua::Error::runtime("table too big to remove from"));
}
set_n(&remove_sizes, &t, n - 1)?;
let taken: Value = t.raw_get(pos)?;
for i in pos..n {
let next: Value = t.raw_get(i + 1)?;
t.raw_set(i, next)?;
}
t.raw_set(n, Value::Nil)?;
Ok(MultiValue::from_vec(vec![taken]))
})?,
)?;
// `table.foreachi 0x7fb4e0` — 1..getn, stop and hand back the first non-nil result.
let foreachi_sizes = sizes.clone();
t.set(
"foreachi",
lua.create_function(move |_, (t, f): (Table, Function)| {
let n = get_n(&foreachi_sizes, &t)?;
for i in 1..=n {
let v: Value = t.raw_get(i)?;
let r: Value = f.call((i, v))?;
if !matches!(r, Value::Nil) {
return Ok(MultiValue::from_vec(vec![r]));
}
}
Ok(MultiValue::new())
})?,
)?;
// `table.concat 0x7fb860` — `j` defaults to getn, everything else is 5.1's own body.
let concat_sizes = sizes.clone();
let concat: Function = t.get("concat")?;
t.set(
"concat",
lua.create_function(
move |_, (t, sep, i, j): (Table, Option<String>, Option<i64>, Option<i64>)| {
let j = match j {
Some(j) => j,
None => get_n(&concat_sizes, &t)?,
};
concat.call::<Value>((t, sep.unwrap_or_default(), i.unwrap_or(1), j))
},
)?,
)?;
// `table.sort 0x7fb900` — sorts `1..getn`, where 5.1's sorts `1..#t`. The two agree unless
// a remembered size is SHORTER than the array border, which is the only case that needs
// anything: lift the tail out, let the C sort run on exactly the elements 5.0 would have
// handed it, put the tail back. (A remembered size LONGER than the border would have 5.0
// comparing against nils and raising; we sort the border instead — a knowing divergence
// from an error path, not from a mechanism.)
let sort_sizes = sizes.clone();
let sort: Function = t.get("sort")?;
t.set(
"sort",
lua.create_function(move |_, (t, cmp): (Table, Option<Function>)| {
let n = get_n(&sort_sizes, &t)?;
let border = i64::try_from(t.raw_len()).unwrap_or(i64::MAX);
if n >= border {
return sort.call::<()>((t, cmp));
}
let tail: Vec<Value> = ((n + 1)..=border)
.map(|i| t.raw_get::<Value>(i))
.collect::<mlua::Result<_>>()?;
for i in (n + 1)..=border {
t.raw_set(i, Value::Nil)?;
}
let sorted = sort.call::<()>((t.clone(), cmp));
for (k, v) in tail.into_iter().enumerate() {
t.raw_set(n + 1 + i64::try_from(k).unwrap_or(0), v)?;
}
sorted
})?,
)?;
// Base `unpack 0x703080` — `1..getn(t)`, by `rawgeti`. The reference takes only the table
// and ignores anything after it, so no 1.12 caller can pass a range meaningfully; we keep
// 5.1's optional `i`/`j` because two of this crate's OWN stdlib helpers pass an explicit
// range to carry embedded nils (`tostringall`, the positional-`format` shim), and nothing
// an addon can write distinguishes the two.
g.set(
"unpack",
lua.create_function(move |_, (t, i, j): (Table, Option<i64>, Option<i64>)| {
let i = i.unwrap_or(1);
let j = match j {
Some(j) => j,
None => get_n(&sizes, &t)?,
};
// The reference's `luaL_checkstack(n, "table too big to unpack")`, at the same
// bound the shifts take.
if j.saturating_sub(i) >= MAX_SHIFT {
return Err(mlua::Error::runtime("too many results to unpack"));
}
let mut out = Vec::new();
for k in i..=j {
out.push(t.raw_get::<Value>(k)?);
}
Ok(MultiValue::from_vec(out))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// **The failure that retired the "revisit if ever traced" note**, in the shape Mik's
/// Scrolling Battle Text 4.43 writes it (`MikCombatEventHelper.lua`'s `EraseTable` →
/// `GetUnorderedCaptureDataTable` → `GetCapturedData`).
///
/// The captures are in the table and `getn` answered 0, so every combat line the addon parsed
/// came back "no match" and it drew nothing at all — through a clean load, a clean session and
/// a `drew=nothing(0)` harness row that read like an addon with nothing to draw.
#[test]
fn a_wipe_then_setn_zero_then_insert_is_countable_again() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>(
r#"
local t = { "stale", "stale" }
for k in pairs(t) do t[k] = nil end
table.setn(t, 0)
table.insert(t, "Kobold Vermin")
table.insert(t, "12")
return table.getn(t)
"#
)
.unwrap(),
2,
"insert must update the remembered size, or getn answers the wiped 0 forever"
);
}
/// `setn`/`getn` are 5.0's real pair, and the side store stays invisible.
///
/// Both halves have been got wrong once each: a no-op `setn` fails the round-trip, and an
/// unconditional `rawset(t, "n", …)` passes it and breaks the 39 addons whose `AceOO-2.0`
/// mixin walk errors on a stray `n` (this module's header).
#[test]
fn the_remembered_size_round_trips_and_never_appears_as_a_key() {
let s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return table.getn({1,2,3})").unwrap(), 3);
assert_eq!(
s.eval::<i64>("local t = {1,2,3} table.setn(t, 7) return table.getn(t)")
.unwrap(),
7
);
assert_eq!(
s.eval::<i64>(
"local t = {1,2,3} table.setn(t, 0) \
local c = 0 for k in pairs(t) do c = c + 1 end return c"
)
.unwrap(),
3,
"a remembered size must not appear as a key"
);
// A table that already carries a numeric `n` keeps using it — 5.0's own first branch, and
// why `arg.n` behaves the way 5.0-era code expects.
assert_eq!(
s.eval::<i64>("local t = {1,2,3, n=3} table.setn(t, 7) return t.n")
.unwrap(),
7
);
// A NEGATIVE `n` is `checkint < 0`, which selects the side store rather than the field —
// `0x6f4ee4`'s `jl`.
assert_eq!(
s.eval::<i64>("local t = {1,2, n=-1} table.setn(t, 5) return table.getn(t) + t.n")
.unwrap(),
4
);
}
/// The count of last resort is `rawgeti` to the first nil, not the `#` border — the two
/// disagree on a holed table, and `#` is not even in this VM's grammar (2101).
///
/// This is the shape every `local t = { f() }` takes when `f` returns a nil in the middle, and
/// it is why `table.getn` is not a way to count returns on 1.12.
#[test]
fn the_fallback_count_stops_at_the_first_hole() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("local t = {} t[1] = 'a' t[3] = 'c' return table.getn(t)")
.unwrap(),
1
);
assert_eq!(s.eval::<i64>("return table.getn({})").unwrap(), 0);
}
/// `insert` with a position, and `remove` — the two that write the size back.
#[test]
fn insert_and_remove_maintain_the_size_the_way_the_reference_does() {
let s = UiScript::new().unwrap();
// A position past the remembered size GROWS it (`if (pos > n) n = pos`).
assert_eq!(
s.eval::<String>(
"local t = {} table.insert(t, 3, 'c') \
return table.getn(t) .. ':' .. tostring(t[3])"
)
.unwrap(),
"3:c"
);
// Insert-at-position shifts up.
assert_eq!(
s.eval::<String>(
"local t = {'a','c'} table.insert(t, 2, 'b') \
return table.concat(t, '') .. ':' .. table.getn(t)"
)
.unwrap(),
"abc:3"
);
// Remove returns the element, closes the gap, and shrinks the size.
assert_eq!(
s.eval::<String>(
"local t = {'a','b','c'} local got = table.remove(t, 1) \
return got .. table.concat(t, '') .. ':' .. table.getn(t)"
)
.unwrap(),
"abc:2"
);
// An empty table answers NO value at all and touches nothing (`if (n <= 0) return 0`).
assert_eq!(
s.eval::<i64>(
"local t = {1,2,3} table.setn(t, 0) \
local got = table.remove(t) \
if got ~= nil then return -1 end return table.getn(t) + t[1]"
)
.unwrap(),
1
);
}
/// The four readers take the remembered size, not the border: `concat`, `sort`, `foreachi`,
/// and base `unpack` (wow-re's call-site census, this module's header).
#[test]
fn concat_sort_foreachi_and_unpack_all_read_the_remembered_size() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<String>("local t = {'a','b','c'} table.setn(t, 2) return table.concat(t, '')")
.unwrap(),
"ab"
);
// sort touches only 1..getn — the tail past it keeps both its values and its order.
assert_eq!(
s.eval::<String>(
"local t = {'c','a','z','y'} table.setn(t, 2) table.sort(t) \
return table.concat(t, '', 1, 4)"
)
.unwrap(),
"aczy"
);
assert_eq!(
s.eval::<String>(
"local t = {'a','b','c'} table.setn(t, 2) local out = '' \
table.foreachi(t, function(i, v) out = out .. v end) return out"
)
.unwrap(),
"ab"
);
assert_eq!(
s.eval::<String>(
"local t = {'a','b','c'} table.setn(t, 2) \
local x, y, z = unpack(t) return tostring(x) .. tostring(y) .. tostring(z)"
)
.unwrap(),
"abnil"
);
// The one superset this module keeps: an EXPLICIT range, which two of our own stdlib
// helpers pass to carry embedded nils. `tostringall`/the positional-`format` shim break
// without it, and no 1.12 caller can observe it (5.0's `unpack` ignores extra arguments).
assert_eq!(
s.eval::<String>(
"local t = {} t[1] = 'a' t[3] = 'c' \
local x, y, z = unpack(t, 1, 3) \
return tostring(x) .. tostring(y) .. tostring(z)"
)
.unwrap(),
"anilc"
);
// ...and with nothing remembered they answer exactly what they always did.
assert_eq!(
s.eval::<String>("return table.concat({'a','b','c'}, '')")
.unwrap(),
"abc"
);
assert_eq!(
s.eval::<String>("local t = {'c','b','a'} table.sort(t) return table.concat(t, '')")
.unwrap(),
"abc"
);
}
/// The bare `getn` global and `table.getn` are one function, and `tinsert`/`tremove` — bound
/// by the stdlib layer, which runs after this one — are the new bodies rather than 5.1's.
#[test]
fn the_bare_aliases_bind_the_5_0_bodies() {
let s = UiScript::new().unwrap();
assert!(s.eval::<bool>("return getn == table.getn").unwrap());
assert!(s.eval::<bool>("return tinsert == table.insert").unwrap());
assert!(s.eval::<bool>("return tremove == table.remove").unwrap());
assert_eq!(
s.eval::<i64>("local t = {1,2,3} table.setn(t, 0) tinsert(t, 9) return t[1]")
.unwrap(),
9
);
}
}
+8 -7
View File
@@ -228,7 +228,7 @@ pub use measure::TextMeasure;
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs;
pub(crate) use model::Model;
pub use model::{TextureProbe, TextureSizeProbe};
pub use model::{FontProbe, TextureProbe, TextureSizeProbe};
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
pub use pet::{PetActionView, PetStats};
pub use pvp::{HonorState, InspectHonorData};
@@ -1525,14 +1525,15 @@ impl UiScript {
/// Its silent sibling is [`Self::report_load_failure`] (decision 1495) — same retention, no
/// dispatch, for the load failures that never raise at all.
pub fn report_script_error(&self, msg: &str) {
let mut model = self.model_mut();
// Retained as a **Load** row, not an Error one (decision 1495): every caller of this is
// the load walk, and from the player's side "the addon's file scope raised" and "the
// addon's file was missing" are the same fact — the addon is not running.
model
.diagnostics
.record(diagnostics::DiagnosticKind::Load, msg);
model.pending_error_dispatch.push(msg.to_string());
// addon's file was missing" are the same fact — the addon is not running. The retention
// half is `diagnostics::record_load_failure`, shared with the demand-load path so the
// rule has one implementation (2107); the dispatch is what makes this the loud sibling.
diagnostics::record_load_failure(&self.lua, msg);
self.model_mut()
.pending_error_dispatch
.push(msg.to_string());
}
/// Hand every queued script error to the Lua-side error handler — the reference's own shape:
+18
View File
@@ -22,6 +22,9 @@ pub type TextureProbe = Box<dyn Fn(&str) -> bool>;
/// The host's texture **texel-size** oracle — see [`Model::texture_size_probe`].
pub type TextureSizeProbe = Box<dyn Fn(&str) -> Option<(u32, u32)>>;
/// The host's font-path resolvability oracle — see [`Model::font_probe`].
pub type FontProbe = Box<dyn Fn(&str) -> bool>;
/// A map from a minted object id to its handle, indexed directly: ids are dense (`next_id`
/// counts from 1), and every scripted method call on a frame or region starts with this lookup
/// — a few hundred a frame from the stock `OnUpdate` sweep alone (decision 1979's UI tick), so
@@ -92,6 +95,20 @@ pub(crate) struct Model {
/// `None` in an engine-less VM (tests, the addon harness), where a zero-size texture keeps the
/// rect it always had — there is no art to measure without a backend.
pub(crate) texture_size_probe: Option<TextureSizeProbe>,
/// The host's font-path oracle ([`super::UiScript::set_font_probe`]): does this font reference
/// load — patch chain or loose addon folder? What lets `SetFont`'s path form answer the
/// reference's **1 | nil**, whose nil is a *load failure* and not an argument error (wow-re
/// `system/ui/ui.md`: `0x79f345`/`0x79f361`, originating at `0x5c1ae0` under the `0x44d040`
/// font-factory cache) — `!OmniCC/main.lua:41`'s `if not Font:SetFont(saved, size) then
/// revert end` is exactly that probe.
///
/// **`None` answers 1 for a non-empty path, which is where this differs from
/// [`Self::texture_probe`]** (decision 2103). A VM with no font backend has no font store to
/// fail against, so nil there would report a load failure that never happened — and it would
/// send `!OmniCC` down its revert branch in every harness run. The texture probe could take
/// the opposite default because nil was already what its engine-less callers observed; here 1
/// is.
pub(crate) font_probe: Option<FontProbe>,
/// Where per-addon saved variables live: the account-scoped folder, then this character's.
/// Both are directories holding one `<Addon>.lua` per declaring addon.
pub(crate) addons_saved_account: Option<std::path::PathBuf>,
@@ -1724,6 +1741,7 @@ impl Model {
measurer: None,
texture_probe: None,
texture_size_probe: None,
font_probe: None,
addons_saved_account: None,
addons_saved_character: None,
framexml_templates: Default::default(),
+105
View File
@@ -105,6 +105,111 @@ pub(super) fn publish_global(lua: &Lua, name: &str, wrapper: &Table) -> mlua::Re
Ok(())
}
/// A widget **name string** argument, already looked up in `_G` — every Lua binding that takes
/// "a frame or its name" resolves one of these ([`prefetch_named_target`] then
/// [`resolve_named_target`]).
///
/// **The client has ONE widget namespace and it is the Lua globals table.** The wrapper binder
/// `0x701bd0` publishes `_G[name] = T` for a named widget (non-overwriting, via `lua_settable`),
/// and every by-name resolver reads that table back — `0x76c760` (globals index → Lua type 5 →
/// **raw** `t[0]` → `lua_touserdata` → `IsA`; `SetParent`'s string arm `0x7a1550`, `SetScrollChild`
/// `0x790fa0`, the XML `parent=` chain) and the layout-vtable `+0x28` entry `0x76c700`, which
/// expands a leading `$parent` (`0x76c5b0`) and then calls `0x76c760`. There is no engine-side
/// name→frame map behind either: for frames and regions a name's only effects are the copy into
/// `[widget+0x98]` and that `_G` publish. (`CSimpleFont` names are the one exception — they key a
/// Storm hash at `0xcf4e78` — and fonts are not anchor targets.) `wow-5875-re`
/// `system/ui/scratch/name-string-widget-resolution.md`; `system/ui/ledger.tsv` rows
/// `0x76c5b0`/`0x76c700`/`0x76c760`/`0x7a2540`.
///
/// So the name is not a *frame name* — it is a **global**, and a frame's published name is only the
/// commonest way one comes to exist. Resolving against a private registry instead makes an alias
/// invisible: `Bar8Button1 = CharacterBag3Slot` at an addon's file scope is a global that is no
/// frame's name, and Bartender2 anchors every bar's buttons through exactly those (decision 2105).
///
/// ## Why the lookup is its own phase
///
/// The reference's read is **`lua_gettable 0x6f3a40` → `luaV_index 0x6f7cf0`**, not `lua_rawget`
/// (the binary holds both, 0xc0 apart; only the former walks `__index`, MAXTAGLOOP 100). So a
/// metatable on `_G` is honoured — and a `__index` can be a *Lua* function that calls straight back
/// into a widget binding. Running that under a live `Model` guard would take a second borrow and
/// panic, so the `_G` read is done with **no guard alive** and the decode happens after.
pub(crate) struct NamedTarget {
/// The name as the client looked it up — after `$parent` expansion where the caller allows it.
/// This is the spelling a diagnostic must quote.
pub(crate) name: String,
/// `_G[name]`, kept only when it is a table (the Lua-type-5 gate). Identity is checked later.
wrapper: Option<Table>,
}
impl NamedTarget {
/// A name argument we could not even read (a non-UTF-8 Lua string) — it names nothing, and the
/// caller's miss path quotes this placeholder the way it always has.
pub(crate) fn unreadable() -> Self {
Self {
name: "<non-utf8>".into(),
wrapper: None,
}
}
}
/// Read `_G[name]` for a name-string argument. **Call with no `Model` borrow alive** — see
/// [`NamedTarget`].
///
/// `parent_base` is the name a leading `$parent` expands to, or `None` for the bindings that do not
/// expand it. `0x76c5b0` has exactly two call sites — `SetName 0x76c691` and `0x76c71c` inside the
/// layout resolver `0x76c700` — so `SetPoint`/`SetAllPoints`' `relativeTo` **is** expanded at
/// runtime, and `SetParent`, `SetScrollChild` and the XML `parent=` chain (which call `0x76c760`
/// directly) are **not**.
pub(crate) fn prefetch_named_target(
lua: &Lua,
name: &str,
parent_base: Option<&str>,
) -> NamedTarget {
let name = match parent_base {
Some(base) => crate::framexml::resolve_name(name, base),
None => name.to_string(),
};
let wrapper = match lua.globals().get::<Value>(name.as_str()) {
Ok(Value::Table(t)) => Some(t),
_ => None,
};
NamedTarget { name, wrapper }
}
/// Decode a [`NamedTarget`] to the stable id of a live frame **or** region (the namespace is one —
/// a Texture or FontString publishes into `_G` on the same rule a Frame does).
///
/// `SetPoint` type-checks against the **root** `CScriptRegion` id `[0xcf0c3c]`, which every widget
/// accepts; `SetParent`/`SetScrollChild` use the narrower Frame id `[0xcf0c10]`, so those callers
/// narrow the result to `id_to_frame` themselves.
pub(crate) fn resolve_named_target(model: &Model, target: &NamedTarget) -> Option<u32> {
let id = decode_id(target.wrapper.as_ref()?).ok()?;
(model.id_to_frame.contains_key(&id) || model.id_to_region.contains_key(&id)).then_some(id)
}
/// The name a leading `$parent` expands to: the first widget at or above `start` with a non-empty
/// name (`0x76c5b0`'s `+0x9c` walk, skipping an empty `GetName`), and
/// [`crate::framexml::DEFAULT_PARENT_NAME`] (`"Top"`) when there is none (rf27 rules 3/5).
///
/// `start` is the anchoring widget's **parent**: a frame's own anchors say `$parent` of its
/// enclosing frame, and a region's say `$parent` of its owner — which is that region's `+0x9c`.
pub(crate) fn parent_token_base(model: &Model, start: Option<FrameHandle>) -> String {
let mut cur = start;
while let Some(p) = cur {
let Some(f) = model.arena.frame(p) else { break };
if let Some(n) = f.name.as_deref().filter(|n| !n.is_empty()) {
return n.to_string();
}
cur = f.parent;
}
crate::framexml::DEFAULT_PARENT_NAME.to_string()
}
/// [`parent_token_base`] for a frame's own anchors — the walk starts at its parent.
pub(crate) fn frame_parent_token_base(model: &Model, h: FrameHandle) -> String {
parent_token_base(model, model.arena.frame(h).and_then(|f| f.parent))
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// String → enum parsing (the client's tables, transcribed in order.rs / layout.rs)
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -193,6 +193,74 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// ── GetFrameType / IsFrameType: the FRAME-side spellings of the pair above (decision 2106) ──
//
// 1.12 registers the type-identity pair **twice, under two names**. The Region script
// (`CScriptRegion`) publishes `GetObjectType`/`IsObjectType` — that is the pair above, and its
// `IsObjectType` binding is `0x7a1290`. The *frame* script (`CSimpleFrameScript.cpp`,
// `__FILE__` `0x879504`) publishes `GetFrameType 0x773640` and `IsFrameType 0x773700` as well,
// and wow-5875-re carved both (`system/ui/scratch/item17-frameapi-batch1.md`, 76-entry
// registrar table verified at the bytes). `GetObjectType`/`IsObjectType` are what LATER
// clients kept; `GetFrameType`/`IsFrameType` are 1.12's own, and we shipped only the first
// pair. That is 1189's superset argument inverted — not an extra name we invented, a real one
// we were missing — and it cost the world map:
//
// Cartographer 2.02, LookNFeel.lua:368, inside OnEnable:
// if v:GetFrameType() == "Model" and not v:GetName() then self.playerModel = v end
//
// With the verb nil that line raised, AceAddon swallowed it, `self.playerModel` stayed nil,
// and the next `Cartographer_ChangeZone` → `SetAlpha` put a red script error on screen from
// `LookNFeel.lua:737` every time the map opened. FuBar's own `FuBar.lua:576`
// (`type(frame:GetFrameType()) ~= "string"`) is the same call.
//
// Both delegate to the same `type_chain` the `Object` pair reads: `GetFrameType` is
// `call [edx+0x1c]` — the identical per-class type-name slot `GetObjectType` reads — then
// `lua_pushstring`, one value, extra arguments ignored. `IsFrameType` walks the chain through
// `[eax+0x18]`, the same case-insensitive whole-string compare, and answers the NUMBER 1 or
// nil.
//
// **One edge is INFERRED and named rather than guessed silently:** wow-re's note glosses
// `IsFrameType`'s *absent-argument* branch as "pushes the frame's own typename (`call [eax+4]`
// GetName)", which is self-contradictory — a typename and a name are different slots — so the
// branch is not settled. We take the sibling's behaviour (the `Usage:` raise) pending a byte
// read. No caller in the 219-addon corpus, the director's AddOns, or the shipped FrameXML ever
// omits the argument: `JIM_toolbox/Config2/Pulse_Config.lua:118` is the corpus's only
// `IsFrameType` site and it passes `"Slider"`.
m.set(
"GetFrameType",
lua.create_function(|lua, this: Table| {
Ok(Value::String(lua.create_string(chain_of(lua, &this)?[0])?))
})?,
)?;
m.set(
"IsFrameType",
lua.create_function(|lua, (this, want): (Table, Value)| {
let chain = chain_of(lua, &this)?;
let want = match &want {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(n) => n.to_string(),
Value::Integer(i) => i.to_string(),
_ => {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let who = model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string());
return Err(mlua::Error::runtime(format!(
"Usage: {who}:IsFrameType(\"TYPE\")"
)));
}
};
Ok(if chain.iter().any(|t| want.eq_ignore_ascii_case(t)) {
Value::Number(1.0)
} else {
Value::Nil
})
})?,
)?;
m.set(
"GetParent",
lua.create_function(|lua, this: Table| {
@@ -334,6 +402,16 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
.unwrap_or_else(|| "<unnamed>".to_string())
};
let parent_arg = it.next();
// `_G[name]` is read with no guard alive (`object::NamedTarget`), and **without**
// `$parent` expansion: `0x7a1550` calls `0x76c760` directly, and only the layout
// vtable's `0x76c700` runs the token.
let named = match &parent_arg {
Some(Value::String(s)) => Some(match s.to_str() {
Ok(n) => super::prefetch_named_target(lua, n.as_ref(), None),
Err(_) => super::NamedTarget::unreadable(),
}),
_ => None,
};
let new_parent = {
let model = lua.app_data_ref::<Model>().expect("model");
match &parent_arg {
@@ -349,11 +427,18 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
))
})?,
),
Some(Value::String(s)) => {
let n = s.to_str()?.to_string();
Some(model.arena.lookup(&n).ok_or_else(|| {
Some(Value::String(_)) => {
// `_G[name]` + the **Frame** tag check — the reference's `0x76c760`
// (`0x7a1550`'s NAME-string path) type-guards against `[0xcf0c10]`, the
// narrow Frame id, not `SetPoint`'s root `[0xcf0c3c]`. So a global naming
// a REGION fails here exactly as an absent one does.
let nt = named.as_ref().expect("a String argument is prefetched");
let hit = super::resolve_named_target(&model, nt)
.and_then(|id| model.id_to_frame.get(&id).copied());
Some(hit.ok_or_else(|| {
mlua::Error::runtime(format!(
"{who}:SetParent(): Couldn't find region named '{n}'"
"{who}:SetParent(): Couldn't find region named '{}'",
nt.name
))
})?)
}
@@ -9,7 +9,35 @@ use crate::layout::{Anchor, Point};
use crate::script::{Model, SCREEN};
use crate::widget::FrameHandle;
use super::{as_f32, decode_id, frame_handle_of, frame_wrapper, point_from_str, point_name};
use super::{
as_f32, decode_id, frame_handle_of, frame_parent_token_base, frame_wrapper, point_from_str,
point_name, prefetch_named_target, resolve_named_target, NamedTarget,
};
/// Phase 1 of a `relativeTo`/`SetAllPoints` target: when the argument is a **name string**, expand
/// a leading `$parent` against `h`'s first named ancestor and read `_G` — all of it with **no**
/// `Model` guard alive, because the reference's read is a gettable and an `__index` on `_G` can run
/// Lua that calls straight back into a binding ([`NamedTarget`]).
///
/// Anything else needs no prefetch and answers `None`.
fn prefetch_relative_to(
lua: &Lua,
arg: Option<&Value>,
h: FrameHandle,
) -> mlua::Result<Option<NamedTarget>> {
let Some(Value::String(s)) = arg else {
return Ok(None);
};
// A name we cannot even read names nothing; the caller's miss path reports it as before.
let Ok(raw) = s.to_str() else {
return Ok(Some(NamedTarget::unreadable()));
};
let base = {
let model = lua.app_data_ref::<Model>().expect("model");
frame_parent_token_base(&model, h)
};
Ok(Some(prefetch_named_target(lua, raw.as_ref(), Some(&base))))
}
/// Populate `m`'s layout (anchor/size) methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
@@ -100,6 +128,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"SetAllPoints",
lua.create_function(|lua, (this, target): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
// The `_G` read runs before the guard — see `set_point` and `object::NamedTarget`.
let named = prefetch_relative_to(lua, Some(&target), h)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let rel_id: u32 = match &target {
// Same id_to_region fallback as `set_point` above (and `region.rs`'s own
@@ -110,18 +140,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id)
})
.unwrap_or_else(|| default_parent_id(&mut model, h)),
Value::String(s) => {
// Frames first, then the region-name registry: the real XML anchors frames to
// REGIONS too (gossip option rows → the greeting FontString) — resolve() binds
// region targets in its second round.
let named = s.to_str().ok().and_then(|n| {
model
.arena
.lookup(n.as_ref())
.map(|hh| model.frame_id(hh))
.or_else(|| model.region_names.get(n.as_ref()).copied())
});
named.unwrap_or_else(|| default_parent_id(&mut model, h))
Value::String(_) => {
// `_G[name]` — the client's one widget namespace, frames and regions alike
// (`object::NamedTarget`). The real XML anchors frames to REGIONS too (gossip
// option rows → the greeting FontString), and both publish there.
let nt = named.as_ref().expect("a String argument is prefetched");
let hit = resolve_named_target(&model, nt);
hit.unwrap_or_else(|| default_parent_id(&mut model, h))
}
_ => default_parent_id(&mut model, h),
};
@@ -358,6 +383,10 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re
.ok_or_else(|| mlua::Error::runtime(format!("SetPoint: unknown point '{point}'")))?;
let h = frame_handle_of(lua, this)?;
// A name target is read out of `_G` FIRST, with no model guard alive — the reference's read is
// a gettable, so an `__index` on `_G` can run Lua that calls back in (`object::NamedTarget`).
let named = prefetch_relative_to(lua, rest.first(), h)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// relativeTo: a table (wrapper) or a string (name), else default to the parent/screen. A table
@@ -379,23 +408,23 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re
})
.unwrap_or_else(|| default_parent_id(&mut model, h))
}
Some(Value::String(s)) => {
Some(Value::String(_)) => {
cursor = 1;
// Frames first, then regions by name (see SetAllPoints above — same rationale).
let named = s.to_str().ok().and_then(|n| {
model
.arena
.lookup(n.as_ref())
.map(|hh| model.frame_id(hh))
.or_else(|| model.region_names.get(n.as_ref()).copied())
});
named.unwrap_or_else(|| {
// The parent fallback is the client's behavior, but a *named* target that
// doesn't resolve is almost always a bug (a typo, or an XML forward reference —
// anchors resolve at SetPoint time, so a target must be declared before its
// dependents). Say so instead of silently misdirecting the anchor: this exact
// silence cost a hunt twice (QuestLogFrame's reward rows, ItemTextFrame's
// scrollbar track).
// `_G[name]`, frames and regions alike (see SetAllPoints above — same rationale), with
// a leading `$parent` already expanded against this frame's first named ancestor.
let nt = named.as_ref().expect("a String argument is prefetched");
let hit = resolve_named_target(&model, nt);
hit.unwrap_or_else(|| {
// **The reference RAISES here** — `luaL_error(0x87ccd4, "%s:SetPoint(): Couldn't
// find region named '%s'")`, no fallback and no no-op (`luaG_errormsg`/`luaD_throw`
// contain no `ret`). Ours still falls back to the parent and warns, because we
// cannot yet see how many of our own transcriptions and of the addon corpus that
// raise would abort — the harness reports no engine warnings, which is the
// instrument that has to come first (decision 2105, "Named, not done").
//
// The warning is worth keeping regardless: a *named* target that doesn't resolve is
// almost always a bug, and this exact silence cost a hunt twice (QuestLogFrame's
// reward rows, ItemTextFrame's scrollbar track).
let who = model
.arena
.frame(h)
@@ -403,7 +432,7 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re
.unwrap_or_else(|| "<anonymous>".into());
model.warnings.push(format!(
"SetPoint({who}): relativeTo '{}' does not resolve — anchored to the parent",
s.to_str().ok().as_deref().unwrap_or("<non-utf8>")
nt.name
));
default_parent_id(&mut model, h)
})
@@ -25,9 +25,9 @@
//! pending layout, then scan its stratum bucket from **its own level upward** for a frame that is
//! not itself, is not one of its descendants (`is_descendant 0x767010`), and whose screen rect
//! intersects non-emptily. The answer is stored as `+0xb4` bit `0x10`.
//! 3. **The raise, gated on that bit** (`0x7651ac..0x7651e7`): `level_compact 0x764eb0` renumbers the
//! stratum's occupied levels contiguously into `[0, count)`, then
//! `set_frame_level(bucket->count, propagate = 1)`.
//! 3. **The raise, gated on that bit** (`0x7651ac..0x7651e7`): `level_compact 0x764eb0` squeezes the
//! free levels out of the stratum — over **every live frame of it, hidden included**
//! (`ui/scratch/level-compact-law.md`) — then `set_frame_level(bucket->count, propagate = 1)`.
//!
//! Three consequences the tests pin: a raise is **`level := top occupied level + 1`**, not a list
//! reshuffle; it is **occlusion-gated** — `Raise()` on a frame that overlaps nothing changes nothing
@@ -52,8 +52,15 @@
//! regardless of when each was last shown. The arithmetic is pinned in
//! `tests/toplevel.rs::a_raise_is_top_occupied_level_plus_one_after_compaction`.
//! - **`level_compact`** is [`crate::widget::WidgetArena::compact_levels`] — an order-preserving
//! renumber of the visible frames in one stratum. It changes no draw order by itself; it is what
//! keeps `level := max + 1` from ratcheting upward for the length of a session.
//! renumber of **every frame** in one stratum, hidden ones included: `0x764eb0` walks the client's
//! master frame list filtering on strata and level alone, and never reads `+0xd4`
//! (`ui/scratch/level-compact-law.md`, decision 2104). It changes no draw order by itself; it is
//! what keeps `level := max + 1` from ratcheting upward for the length of a session. Renumbering
//! only the *bucket* (the visible half) is the one shape it may not have: the raise's `propagate`
//! delta is computed in the new numbering and applied to levels that are still in the old one, so
//! a squeeze taken out of a visible child and not out of its hidden sibling inverts a pair that
//! `parent.level + 1` made equal — see that method's own doc for the window it broke, and for the
//! two places ours is deliberately simpler than the bytes.
//! - **The OVERLAPPED bit is computed, never stored** — and that is *exact*, not a shortcut. The bit
//! is written only by `0x7650f0` and the per-tick pass `0x7657d0`, and read only by `0x7650f0`,
//! which with `force = 1` recomputes it immediately before reading it. The stored value is
+78 -31
View File
@@ -8,6 +8,7 @@ use mlua::{Lua, Table, Value};
use super::object::{
anchor_bits_eq, anchor_retarget_is_structural, as_f32, decode_id, id_to_lud, point_from_str,
NamedTarget,
};
use super::{
Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS,
@@ -349,6 +350,15 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
};
let wrong_type =
|| mlua::Error::runtime("SetParent(): Wrong parent object type, expected frame");
// `_G[name]` before the guard, and **without** `$parent` expansion — the reparent
// bindings call `0x76c760` directly (`super::object::NamedTarget`).
let named = match &parent {
Value::String(s) => Some(match s.to_str() {
Ok(n) => super::object::prefetch_named_target(lua, n.as_ref(), None),
Err(_) => NamedTarget::unreadable(),
}),
_ => None,
};
let mut model = lua.app_data_mut::<Model>().expect("model");
let new_owner = match &parent {
Value::Nil => None,
@@ -360,14 +370,17 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
.and_then(|id| model.id_to_frame.get(&id).copied())
.ok_or_else(wrong_type)?,
),
// A name resolves through the frame registry, as every other frame-target argument
// does (`SetPoint`'s relativeTo, `SetParent` on the frame side).
Value::String(s) => {
let name = s.to_str()?;
Some(model.arena.lookup(name.as_ref()).ok_or_else(|| {
// A name resolves through `_G[name]` + the narrow **Frame** tag check
// (`[0xcf0c10]`), as every other frame-target argument does — the reference's
// `0x76c760`; see `object::NamedTarget`.
Value::String(_) => {
let nt = named.as_ref().expect("a String argument is prefetched");
let hit = super::object::resolve_named_target(&model, nt)
.and_then(|id| model.id_to_frame.get(&id).copied());
Some(hit.ok_or_else(|| {
mlua::Error::runtime(format!(
"SetParent(): Couldn't find region named '{}'",
name.as_ref()
nt.name
))
})?)
}
@@ -701,33 +714,62 @@ pub(crate) fn implicit_creation_anchor_lua(lua: &Lua, wrapper: &Table) -> mlua::
Ok(())
}
/// Resolve a `SetPoint`/`SetAllPoints` `relativeTo` argument (a frame/region wrapper table, a frame
/// name, or nil) to a layout id, defaulting to `owner` when absent/unresolved.
pub(super) fn resolve_target(model: &mut Model, target: &Value, owner: u32) -> u32 {
/// Phase 1 of a region's `relativeTo`/`SetAllPoints` target — the frame twin is
/// `object::layout_methods`' `prefetch_relative_to`, and the reason is the same: the reference
/// reads `_G` with a *gettable*, so the read must happen with **no** `Model` guard alive
/// ([`super::object::NamedTarget`]).
///
/// A region's `$parent` is its **owner** frame (the region's own `+0x9c`), so the walk starts
/// there rather than one link higher.
pub(super) fn prefetch_region_target(
lua: &Lua,
target: &Value,
rh: RegionHandle,
) -> Option<NamedTarget> {
let Value::String(s) = target else {
return None;
};
let Ok(raw) = s.to_str() else {
return Some(NamedTarget::unreadable());
};
let base = {
let model = lua.app_data_ref::<Model>().expect("model");
let owner = model.arena.region(rh).map(|r| r.owner);
super::object::parent_token_base(&model, owner)
};
Some(super::object::prefetch_named_target(
lua,
raw.as_ref(),
Some(&base),
))
}
/// Resolve a `SetPoint`/`SetAllPoints` `relativeTo` argument (a frame/region wrapper table, a
/// widget name prefetched by [`prefetch_region_target`], or nil) to a layout id, defaulting to
/// `owner` when absent/unresolved.
pub(super) fn resolve_target(
model: &mut Model,
target: &Value,
named: Option<&NamedTarget>,
owner: u32,
) -> u32 {
match target {
Value::Table(t) => decode_id(t)
.ok()
.filter(|id| model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id))
.unwrap_or(owner),
Value::String(s) => s
.to_str()
.ok()
.and_then(|n| {
// Frames first (the client's global namespace is one; frames publish before their
// regions build), then the region-name registry — the real XML anchors regions to
// sibling regions by name (merchant label plate → `$parentSlot`).
model
.arena
.lookup(n.as_ref())
.map(|h| model.frame_id(h))
.or_else(|| model.region_names.get(n.as_ref()).copied())
})
.unwrap_or_else(|| {
// The owner fallback matches the frame path, but a *named* target that doesn't
// resolve is almost always a bug — a typo, or an XML forward reference (anchors
// resolve at SetPoint time, so a target must be declared before its dependents;
// ItemTextFrame's scrollbar track landed on the parchment this way). Warn
// instead of silently misdirecting the anchor.
Value::String(_) => {
// The client's global namespace is ONE, so a frame and a region answer the same
// lookup — the real XML anchors regions to sibling regions by name too (merchant
// label plate → `$parentSlot`, and that `$parent` is expanded on this path).
let nt = named.expect("a String argument is prefetched");
let hit = super::object::resolve_named_target(model, nt);
hit.unwrap_or_else(|| {
// The reference RAISES on this leg (`0x87ccd4`); ours warns and falls back to the
// owner — the frame twin carries the why, and decision 2105 the deferral. The
// warning earns its keep either way: a *named* target that doesn't resolve is
// almost always a bug (a typo, or an XML forward reference — ItemTextFrame's
// scrollbar track landed on the parchment this way).
let who = model
.id_to_frame
.get(&owner)
@@ -736,10 +778,11 @@ pub(super) fn resolve_target(model: &mut Model, target: &Value, owner: u32) -> u
.unwrap_or_else(|| "<anonymous>".into());
model.warnings.push(format!(
"SetPoint(region of {who}): relativeTo '{}' does not resolve — anchored to the owner",
s.to_str().ok().as_deref().unwrap_or("<non-utf8>")
nt.name
));
owner
}),
})
}
_ => owner,
}
}
@@ -769,6 +812,10 @@ pub(super) fn region_set_point(
let point = point_from_str(point)
.ok_or_else(|| mlua::Error::runtime(format!("SetPoint: unknown point '{point}'")))?;
let rh = region_handle_of(lua, this)?;
// The `_G` read runs before the guard — see `prefetch_region_target`.
let named = rest
.first()
.and_then(|v| prefetch_region_target(lua, v, rh));
let mut model = lua.app_data_mut::<Model>().expect("model");
let owner = region_owner_id(&mut model, rh);
@@ -776,7 +823,7 @@ pub(super) fn region_set_point(
let rel_to_id: u32 = match rest.first() {
Some(Value::Table(_) | Value::String(_) | Value::Nil) => {
cursor = 1;
resolve_target(&mut model, &rest[0], owner)
resolve_target(&mut model, &rest[0], named.as_ref(), owner)
}
// A leading number is the `SetPoint(point, x, y)` overload — cursor stays at 0.
_ => owner,
@@ -149,9 +149,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"SetAllPoints",
lua.create_function(|lua, (this, target): (Table, Value)| {
let rh = region_handle_of(lua, &this)?;
// The `_G` read runs before the guard — see `region::prefetch_region_target`.
let named = super::prefetch_region_target(lua, &target, rh);
let mut model = lua.app_data_mut::<Model>().expect("model");
let owner = region_owner_id(&mut model, rh);
let rel_id = resolve_target(&mut model, &target, owner);
let rel_id = resolve_target(&mut model, &target, named.as_ref(), owner);
let pair = [
Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0),
@@ -550,6 +550,14 @@ impl crate::script::UiScript {
self.model_mut().texture_probe = Some(probe);
}
/// Install the host's font-path oracle — the resolver behind `SetFont`'s **1 | nil** return
/// ([`Model::font_probe`], decision 2103). The host hands in load-ability over its real stores
/// (patch chain + the one AddOns folder); a VM that never gets one keeps answering 1 for every
/// non-empty path, because it has no font store a load could fail against.
pub fn set_font_probe(&mut self, probe: crate::script::FontProbe) {
self.model_mut().font_probe = Some(probe);
}
/// Install the host's texture **texel-size** oracle — what lets a region with an authored size
/// of `0` on an axis take that span from its art, as the client's virtual size getters do
/// ([`Model::texture_size_probe`], decision 1349 / wow-re `region-size-fallback.md` §2). A VM
+14 -3
View File
@@ -163,15 +163,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetScrollChild",
lua.create_function(|lua, (this, target): (Table, Value)| {
// `_G[name]` before the guard, no `$parent` — `0x790fa0`'s string arm calls `0x76c760`
// directly, the same narrow-Frame-tag resolver `SetParent` uses
// (`object::NamedTarget`).
let named = match &target {
Value::String(s) => s
.to_str()
.ok()
.map(|n| super::object::prefetch_named_target(lua, n.as_ref(), None)),
_ => None,
};
let new_child: Option<FrameHandle> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match &target {
Value::Table(t) => decode_id(t)
.ok()
.and_then(|id| model.id_to_frame.get(&id).copied()),
Value::String(s) => {
s.to_str().ok().and_then(|n| model.arena.lookup(n.as_ref()))
}
Value::String(_) => named.as_ref().and_then(|nt| {
super::object::resolve_named_target(&model, nt)
.and_then(|id| model.id_to_frame.get(&id).copied())
}),
_ => None,
}
};
+24 -20
View File
@@ -308,10 +308,12 @@ function wipe(t)
return t
end
-- 5.0 spelling throughout this file: a vararg function reads `arg`/`arg.n`, never `...` as a
-- value, because 1.12's Lua has no grammar for that (decision 2101) and neither does ours.
function tostringall(...)
local n = select('#', ...)
local n = arg.n
local t = {}
for i = 1, n do t[i] = tostring((select(i, ...))) end
for i = 1, n do t[i] = tostring(arg[i]) end
return unpack(t, 1, n)
end
@@ -321,31 +323,32 @@ end
function strsplit(delim, str, limit)
local set = "[" .. delim:gsub("(.)", "%%%1") .. "]"
local result = {}
local n = 0
local start = 1
while true do
if limit and #result >= limit - 1 then
result[#result + 1] = str:sub(start)
if limit and n >= limit - 1 then
n = n + 1; result[n] = str:sub(start)
break
end
local s, e = str:find(set, start)
if not s then
result[#result + 1] = str:sub(start)
n = n + 1; result[n] = str:sub(start)
break
end
result[#result + 1] = str:sub(start, s - 1)
n = n + 1; result[n] = str:sub(start, s - 1)
start = e + 1
end
return unpack(result)
return unpack(result, 1, n)
end
-- strjoin(sep, ...): join the varargs with `sep`.
function strjoin(sep, ...)
return table.concat({ ... }, sep)
return table.concat(arg, sep, 1, arg.n)
end
-- strconcat(...): concatenate all args (WoW's join with no separator).
function strconcat(...)
return table.concat({ ... })
return table.concat(arg, "", 1, arg.n)
end
-- strtrim(s [, chars]): trim leading/trailing chars (default whitespace).
@@ -362,44 +365,45 @@ do
local function reformat(fmt, ...)
if type(fmt) ~= "string" then
return _format(fmt, ...)
return _format(fmt, unpack(arg, 1, arg.n))
end
-- fast path: no "%<digit>" at all ⇒ definitely no positional spec.
if not fmt:find("%%%d") then
return _format(fmt, ...)
return _format(fmt, unpack(arg, 1, arg.n))
end
local args = { ... }
local args = arg
local pieces = {} -- rebuilt (sequential) format fragments
local order = {} -- for each real conversion: the source arg index, or false=sequential
local seen_pos, seen_seq = false, false
local i, len = 1, #fmt
local npieces, norder = 0, 0
local i, len = 1, string.len(fmt)
while i <= len do
local c = fmt:sub(i, i)
if c ~= "%" then
pieces[#pieces + 1] = c
npieces = npieces + 1; pieces[npieces] = c
i = i + 1
elseif fmt:sub(i + 1, i + 1) == "%" then
pieces[#pieces + 1] = "%%"
npieces = npieces + 1; pieces[npieces] = "%%"
i = i + 2
else
local j = i + 1
local ds, de = fmt:find("^%d+%$", j) -- optional N$
if ds then
seen_pos = true
order[#order + 1] = tonumber(fmt:sub(ds, de - 1))
norder = norder + 1; order[norder] = tonumber(fmt:sub(ds, de - 1))
j = de + 1
else
seen_seq = true
order[#order + 1] = false
norder = norder + 1; order[norder] = false
end
-- copy flags/width/precision + the conversion letter
local ce = fmt:find("[" .. CONV .. "]", j)
if not ce then
error("invalid conversion in format string", 2)
end
pieces[#pieces + 1] = "%" .. fmt:sub(j, ce)
npieces = npieces + 1; pieces[npieces] = "%" .. fmt:sub(j, ce)
i = ce + 1
end
end
@@ -409,7 +413,7 @@ do
end
local out, seq = {}, 0
for k = 1, #order do
for k = 1, norder do
local idx = order[k]
if idx == false then
seq = seq + 1
@@ -418,7 +422,7 @@ do
out[k] = args[idx]
end
end
return _format(table.concat(pieces), unpack(out, 1, #order))
return _format(table.concat(pieces, "", 1, npieces), unpack(out, 1, norder))
end
string.format = reformat
+7 -2
View File
@@ -1,4 +1,9 @@
//! RegisterEvent + fire_event via BOTH conventions (RF-0025).
//!
//! The handler's extra arguments are read through 5.0's implicit `arg` table, not `select(n, ...)`:
//! `...` as a value is not in this VM's grammar (decision 2101), because it is not in the 1.12
//! client's. The point of the test is unchanged — the same handler sees the legacy globals
//! (`this`, `event`, `arg1`) AND the positional arguments.
use super::common::script;
use crate::script::*;
@@ -14,9 +19,9 @@ fn fire_event_both_conventions_in_one_handler() {
r_this_eq_self = (this == self) -- legacy `this` global == modern `self`
r_event_global = event -- modern `event` arg
r_event_eq = (event == _G.event) -- == legacy `event` global
r_arg1_eq = (arg1 == select(1, ...)) -- legacy `arg1` == modern select(1,...)
r_arg1_eq = (arg1 == arg[1]) -- legacy `arg1` global == the vararg table
r_arg1 = arg1
r_arg2 = select(2, ...)
r_arg2 = arg[2]
end)
"#,
)
@@ -1029,3 +1029,55 @@ fn create_font_string_applies_the_font_object_named_by_its_third_argument() {
s.run(r#"TX = Host:CreateTexture(nil, "OVERLAY")"#)
.expect("the two-argument form still works");
}
/// **`SetFont`'s nil is a LOAD failure, and only the host knows** (decision 2103).
///
/// The reference answers the number 1 or nil (`0x79f345`/`0x79f361`), and the nil originates in
/// the font factory at `0x5c1ae0` — a path that names no readable file. `!OmniCC/main.lua:41`
/// reads it exactly that way (`if not Font:SetFont(saved, size) then revert end`), and an addon
/// that ships its own faces (MSBT ships thirty-one, under `Interface\Addons\…\Fonts\`) is the
/// case that makes the answer depend on a store rather than on the string.
///
/// The engine-less default is the other half: with no probe a non-empty path answers 1, because a
/// VM with no font backend has nothing for a load to fail against. That is the opposite default to
/// `SetTexture`'s (1322) and the reason is in `Model::font_probe`.
#[test]
fn set_font_answers_the_hosts_load_verdict_when_there_is_a_host() {
let mut s = script();
s.run("FS = CreateFrame('Frame'):CreateFontString()")
.unwrap();
// No probe: any non-empty path is 1, an empty one is nil.
assert!(s
.eval::<bool>(r"return FS:SetFont('Interface\\Addons\\Nope\\Fonts\\x.ttf', 12) == 1")
.unwrap());
assert!(s.eval::<bool>("return FS:SetFont('', 12) == nil").unwrap());
// With one, the store decides — and the face it refused is NOT adopted, so the region keeps
// the last font that did load.
s.set_font_probe(Box::new(|path| {
path.eq_ignore_ascii_case("interface\\addons\\msbt\\fonts\\porky.ttf")
|| path.eq_ignore_ascii_case("fonts\\frizqt__.ttf")
}));
assert!(s
.eval::<bool>(r"return FS:SetFont('Fonts\\FRIZQT__.TTF', 12) == 1")
.unwrap());
assert!(
s.eval::<bool>(r"return FS:SetFont('Interface\\AddOns\\MSBT\\Fonts\\porky.ttf', 18) == 1")
.unwrap(),
"an addon's own face, spelled in its own case, must load"
);
assert!(
s.eval::<bool>(r"return FS:SetFont('Interface\\AddOns\\MSBT\\Fonts\\gone.ttf', 18) == nil")
.unwrap(),
"a path the store does not hold is the reference's falsey load failure"
);
let (face, height, _) = s
.eval::<(String, f32, String)>("return FS:GetFont()")
.unwrap();
assert_eq!(
face, "Interface\\AddOns\\MSBT\\Fonts\\porky.ttf",
"the refused face must not replace the one that loaded"
);
assert_eq!(height, 18.0, "…while the height, which never fails, is set");
}
@@ -26,6 +26,7 @@ mod minimap;
mod model_clock;
mod modelframe;
mod movable;
mod name_targets;
mod object_model;
mod reference_surface;
mod region_map;
@@ -0,0 +1,244 @@
//! **A widget NAME argument is a Lua GLOBAL, not a frame name** — the one rule behind every
//! binding that takes "a frame or its name": `SetPoint`'s `relativeTo`, `SetAllPoints`,
//! `SetParent`, `SetScrollChild`.
//!
//! The client has a single widget namespace and it is `_G`. The wrapper binder `0x701bd0`
//! publishes `_G[name] = T` for a named widget (non-overwriting), and the by-name resolvers read
//! that table back and nothing else — `0x76c760` (FrameScript globals **rawget** + Lua-type-5 +
//! the `vtbl+0x10` tag check) and its geometry-vtable `+0x28` twin `0x76c700`
//! (`_G[name]` → `t[0]` userdata → `IsA`). `wow-5875-re` `system/ui/ledger.tsv` rows 7972/7973,
//! `system/ui/scratch/worldmap-arrow-and-positions.md` §2.4, `system/ui/ui.md` §RF-0023(c).
//!
//! A frame's published name is therefore only the commonest way such a global comes to exist —
//! **an alias is just as good a name**, and 1.12 addons lean on that. Bartender2 2.0 aliases every
//! stock button (`Bar8Button1 = CharacterBag3Slot`, file scope, `Alias.lua`) and then lays each bar
//! out by anchoring button *i* to the string `"Bar8Button"..(i-1)`. Resolving those against a
//! private frame-name registry finds nothing, every anchor falls back to the parent, and all five
//! bag buttons land in one spot on the bar's far corner. Decision 2105.
use super::common::script;
/// Bartender2's exact shape: stock buttons, addon alias globals that no frame is named, a row laid
/// out through those aliases alone. The row must come out at the addon's pitch.
#[test]
fn setpoint_relative_to_resolves_an_alias_global() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
-- The reference UI's own frames.
Bar = CreateFrame("Frame", "Bar")
Bar:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 50)
Bar:SetSize(200, 40)
for i = 1, 5 do
local b = CreateFrame("Button", "StockButton" .. i, Bar)
b:SetSize(30, 30)
end
-- The addon's aliases: plain globals, no frame's name (Bartender2's Alias.lua).
Alias1 = StockButton1
Alias2 = StockButton2
Alias3 = StockButton3
Alias4 = StockButton4
Alias5 = StockButton5
-- ...and the layout it drives through them (Bartender2's SetupBar8).
for i = 1, 5 do getglobal("Alias" .. i):ClearAllPoints() end
Alias1:SetPoint("BOTTOMLEFT", "Bar", "BOTTOMLEFT", 5, 5)
for i = 2, 5 do
getglobal("Alias" .. i):SetPoint("BOTTOMLEFT", "Alias" .. (i - 1), "BOTTOMRIGHT", 2, 0)
end
"#,
)
.unwrap();
s.resolve();
// Bar's BOTTOMLEFT is (100, 50); button 1 sits at +(5, 5) and each next one 30 + 2 further on.
for i in 1..=5 {
let left: f32 = s
.eval(&format!("return StockButton{i}:GetLeft()"))
.unwrap_or_else(|e| panic!("StockButton{i}:GetLeft(): {e}"));
let bottom: f32 = s
.eval(&format!("return StockButton{i}:GetBottom()"))
.unwrap();
assert_eq!(
left,
105.0 + 32.0 * (i - 1) as f32,
"button {i} must sit one pitch past its predecessor, not stacked on the bar"
);
assert_eq!(bottom, 55.0, "the row shares one baseline");
}
assert!(
s.take_warnings().is_empty(),
"an alias global is a resolvable target, not a miss"
);
}
/// The namespace is ONE: a **region** published under a global resolves as a `relativeTo` exactly
/// as a frame does (the real XML anchors frames to FontStrings by name — gossip option rows).
#[test]
fn setpoint_relative_to_resolves_a_region_alias_global() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Panel = CreateFrame("Frame", "Panel")
Panel:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0)
Panel:SetSize(400, 300)
local tex = Panel:CreateTexture("PanelSwatch", "ARTWORK")
tex:SetPoint("BOTTOMLEFT", "Panel", "BOTTOMLEFT", 10, 20)
tex:SetSize(50, 50)
Swatch = PanelSwatch -- the alias
Tag = CreateFrame("Frame", "Tag", Panel)
Tag:SetSize(10, 10)
Tag:SetPoint("BOTTOMLEFT", "Swatch", "BOTTOMRIGHT", 4, 0)
"#,
)
.unwrap();
s.resolve();
let left: f32 = s.eval("return Tag:GetLeft()").unwrap();
let bottom: f32 = s.eval("return Tag:GetBottom()").unwrap();
assert_eq!(
left, 64.0,
"10 + 50 + 4 — anchored to the texture, not to Panel"
);
assert_eq!(bottom, 20.0);
}
/// A global that is not a widget resolves to nothing — the reference's type-5 + tag check. The
/// binding then takes its usual unresolved-target path (parent + a warning), never the value.
#[test]
fn setpoint_relative_to_ignores_a_non_widget_global() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Host = CreateFrame("Frame", "Host")
Host:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 10, 10)
Host:SetSize(100, 100)
NotAWidget = 5
Child = CreateFrame("Frame", "Child", Host)
Child:SetSize(20, 20)
Child:SetPoint("BOTTOMLEFT", "NotAWidget", "BOTTOMRIGHT", 0, 0)
"#,
)
.unwrap();
s.resolve();
// Fell back to the parent Host, whose BOTTOMRIGHT is (110, 10).
let left: f32 = s.eval("return Child:GetLeft()").unwrap();
assert_eq!(left, 110.0);
let warnings = s.take_warnings();
assert!(
warnings.iter().any(|w| w.contains("NotAWidget")),
"an unresolved named target is reported, not silently redirected: {warnings:?}"
);
}
/// **`$parent` is expanded at RUNTIME, on the anchor path only.** `0x76c5b0` has exactly two call
/// sites — `SetName 0x76c691` and `0x76c71c` *inside* the layout resolver `0x76c700` — so
/// `SetPoint`/`SetAllPoints` run the token against the anchoring frame's first **named** ancestor
/// (skipping anonymous links, `"Top"` when there is none), while `SetParent` and `SetScrollChild`,
/// which call `0x76c760` directly, do not.
#[test]
fn setpoint_relative_to_expands_parent_against_the_first_named_ancestor() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Root = CreateFrame("Frame", "Root")
Root:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0)
Root:SetSize(300, 200)
Peg = CreateFrame("Frame", "RootPeg", Root)
Peg:SetPoint("BOTTOMLEFT", "Root", "BOTTOMLEFT", 20, 30)
Peg:SetSize(10, 10)
-- An ANONYMOUS link in between: the walk skips it and lands on Root.
Anon = CreateFrame("Frame", nil, Root)
Anon:SetAllPoints(Root)
Kid = CreateFrame("Frame", "Kid", Anon)
Kid:SetSize(5, 5)
Kid:SetPoint("BOTTOMLEFT", "$parentPeg", "BOTTOMRIGHT", 0, 0)
"#,
)
.unwrap();
s.resolve();
let (l, b): (f32, f32) = s.eval("return Kid:GetLeft(), Kid:GetBottom()").unwrap();
assert_eq!(
(l, b),
(30.0, 30.0),
"`$parentPeg` is RootPeg (20 + its 10 wide), not an unresolvable literal"
);
assert!(s.take_warnings().is_empty());
}
/// The other half of the same fact: the reparent bindings never see the token, so a
/// `$parent`-prefixed name reaches them literally and fails to resolve.
#[test]
fn setparent_does_not_expand_parent() {
let s = script();
s.run(
r#"
Root = CreateFrame("Frame", "Root")
RootPeg = CreateFrame("Frame", "RootPeg", Root)
Kid = CreateFrame("Frame", "Kid", Root)
"#,
)
.unwrap();
let err = s
.eval::<()>(r#"Kid:SetParent("$parentPeg")"#)
.expect_err("an unexpanded token names nothing");
assert!(
err.to_string().contains("$parentPeg"),
"the raise quotes the name as passed: {err}"
);
}
/// `SetParent` takes the same namespace (`0x7a1550`'s NAME-string path → `0x76c760`) — Bartender2
/// re-parents every stock button by alias before it anchors it.
#[test]
fn setparent_resolves_an_alias_global() {
let s = script();
s.run(
r#"
Holder = CreateFrame("Frame", "Holder")
Moved = CreateFrame("Frame", "Moved")
HolderAlias = Holder
Moved:SetParent("HolderAlias")
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return Moved:GetParent():GetName()")
.unwrap(),
"Holder"
);
}
/// `SetAllPoints` shares the resolver, so it shares the rule.
#[test]
fn setallpoints_resolves_an_alias_global() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Plate = CreateFrame("Frame", "Plate")
Plate:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 30, 40)
Plate:SetSize(120, 60)
PlateAlias = Plate
Overlay = CreateFrame("Frame", "Overlay")
Overlay:SetAllPoints("PlateAlias")
"#,
)
.unwrap();
s.resolve();
let (l, b, w, h): (f32, f32, f32, f32) = s
.eval(
"return Overlay:GetLeft(), Overlay:GetBottom(), Overlay:GetWidth(), Overlay:GetHeight()",
)
.unwrap();
assert_eq!((l, b, w, h), (30.0, 40.0, 120.0, 60.0));
}
@@ -1394,6 +1394,92 @@ fn the_type_identity_verbs_answer_what_the_binary_answers() {
);
}
/// **`GetFrameType`/`IsFrameType` — 1.12's own names for the pair above, on the FRAME side**
/// (decision 2106).
///
/// The client registers type identity twice: `CScriptRegion` publishes
/// `GetObjectType`/`IsObjectType` (the test above), and `CSimpleFrameScript.cpp` publishes
/// `GetFrameType 0x773640` / `IsFrameType 0x773700` — both carved at the bytes in wow-5875-re
/// `system/ui/scratch/item17-frameapi-batch1.md`. Later clients kept the first pair and dropped
/// the second; we had shipped only the first, which is the inverse of 1189's usual failure — a
/// real 1.12 name we were *missing*, not an invented one we offered.
///
/// It cost the world map. Cartographer 2.02's `LookNFeel.lua:368` finds the map's player arrow
/// with `v:GetFrameType() == "Model" and not v:GetName()`; with the verb nil that raised inside
/// `OnEnable`, AceAddon swallowed it, and every map open then put
/// `LookNFeel.lua:737: attempt to index field 'playerModel'` on screen.
#[test]
fn the_frame_side_type_identity_verbs_are_1_12s_own_names() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
FTFrame = CreateFrame("Frame", "FTFrame")
FTButton = CreateFrame("Button", "FTButton")
FTModel = CreateFrame("Model", nil, FTFrame)
FTTex = FTFrame:CreateTexture("FTTex", "ARTWORK")
"#,
)
.unwrap();
// `GetFrameType` reads the same per-class type-name slot `GetObjectType` does (`[edx+0x1c]`),
// so the two agree by construction — one value, extra arguments ignored.
for (obj, leaf) in [
("FTFrame", "Frame"),
("FTButton", "Button"),
("FTModel", "Model"),
] {
assert_eq!(
s.eval::<String>(&format!("return {obj}:GetFrameType()"))
.unwrap(),
leaf
);
assert_eq!(
s.eval::<String>(&format!("return {obj}:GetObjectType()"))
.unwrap(),
leaf,
"the two names must never disagree — they read one slot"
);
}
// The exact line Cartographer walks the world map with.
assert!(
s.eval::<bool>(r#"return FTModel:GetFrameType() == "Model" and not FTModel:GetName()"#)
.unwrap(),
"an anonymous Model must answer its type AND a nil name — LookNFeel.lua:368"
);
// `IsFrameType` walks the same chain: the leaf, every base, the number 1 on a hit and nil on
// a miss, case-insensitive and whole-string.
assert_eq!(
s.eval::<i64>("return FTButton:IsFrameType('Button')")
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>("return FTButton:IsFrameType('frame')")
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>("return FTButton:IsFrameType('REGION')")
.unwrap(),
1
);
for absent in ["LayoutFrame", "ScriptObject", "Object", "Slider", "Fram"] {
assert!(
s.eval::<Option<i64>>(&format!("return FTButton:IsFrameType('{absent}')"))
.unwrap()
.is_none(),
"{absent} is not in a Button's chain"
);
}
// A REGION does not get the frame-side pair — the two registrars are distinct, and offering
// both names on everything would be the superset 1189 forbids.
assert!(s.eval::<bool>("return FTTex.GetFrameType == nil").unwrap());
assert!(s.eval::<bool>("return FTTex.IsFrameType == nil").unwrap());
}
/// **The frame side of the type identity, and the three chains a guess gets wrong.**
///
/// `_Nameplates.lua` is why this half exists: it asks `Region:GetObjectType()` (the region twin)
+12 -12
View File
@@ -242,23 +242,23 @@ fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
local n, first, second = varargs("a", "b")
assert(n == 2 and first == "a" and second == "b")
-- **The one real edge**, and it is compat mode's rule rather than a gap: `arg` is
-- synthesized only for a vararg function that does NOT also mention `...` in its body.
-- Use both spellings in one function and `arg` is nil. Vanilla addon code is uniformly
-- 5.0 and never mixes them, so this costs an addon nothing — it only means one of OUR
-- transcriptions must pick a spelling per function and stay with it.
local function mixed(...) return arg == nil and select("#", ...) or -1 end
assert(mixed(1, 2, 3) == 3)
-- `...` alone is the 5.1 spelling and is unaffected.
local function modern(...) return select("#", ...) end
assert(modern("x", "y") == 2)
-- The edge that used to be here is gone (decision 2101). `arg` was synthesized only for
-- a vararg function that did NOT also mention `...` in its body, so mixing the two
-- spellings in one function left `arg` nil. `...` as a value is no longer in the grammar
-- at all, so nothing can clear the flag and EVERY vararg function has its `arg`.
local function fixed_and_varargs(a, ...) return a, arg.n, arg[1] end
local a, n, first = fixed_and_varargs("a", "b", "c")
assert(a == "a" and n == 2 and first == "b")
-- 5.0's table/string/math spellings, all of which 5.1 renamed.
assert(table.getn({ 1, 2, 3 }) == 3)
assert(string.gfind ~= nil) -- 5.1 renamed this to string.gmatch
assert(math.mod(7, 3) == 1)
assert(7 % 3 == 1) -- and the 5.1 operator 5.0 lacks also works
-- ...and the 5.1 OPERATORS 5.0 lacks are not in the grammar: `%`, `#`, and `...` as a
-- value all fail to compile, exactly as they do on the 1.12 client (2101).
assert(loadstring("return 7 % 3") == nil)
assert(loadstring("return #({1})") == nil)
assert(loadstring("return function(...) return ... end") == nil)
"##,
)
.unwrap();
@@ -231,12 +231,18 @@ fn set_inventory_item_renders_full_outside_compare() {
-- empty slot answering ONE value hands its caller a nil where a number belongs. pfUI's
-- durability scan (panel.lua:499) does `totalRep + repCost` with no guard at all and died
-- exactly there.
local n = { tt:SetInventoryItem("player", 16) }
assert(table.getn(n) == 3, "occupied: three returns, got " .. table.getn(n))
assert(n[3] == 0, "repairCost is a NUMBER — the reference always pushes one; 0 INTERIM")
local e = { tt:SetInventoryItem("player", 5) }
assert(table.getn(e) == 3, "empty: three returns too, got " .. table.getn(e))
assert(e[1] == nil and e[3] == 0, "empty slot: no item, but still a numeric repairCost")
-- Counted with `select`, NOT `table.getn` on a captured table: 5.0's `luaL_getn` counts
-- rawgeti to the first nil (decision 2102), so `{ f() }` where f answers `1, nil, 0`
-- measures ONE — a hole, not a short return.
assert(select('#', tt:SetInventoryItem("player", 16)) == 3,
"occupied: three returns, got " .. select('#', tt:SetInventoryItem("player", 16)))
local _, _, repairCost = tt:SetInventoryItem("player", 16)
assert(repairCost == 0, "repairCost is a NUMBER — the reference always pushes one; 0 INTERIM")
assert(select('#', tt:SetInventoryItem("player", 5)) == 3,
"empty: three returns too, got " .. select('#', tt:SetInventoryItem("player", 5)))
local hasItem, _, emptyCost = tt:SetInventoryItem("player", 5)
assert(hasItem == nil and emptyCost == 0,
"empty slot: no item, but still a numeric repairCost")
"#,
)
.unwrap();
@@ -689,3 +689,115 @@ fn a_chorded_press_raises_the_held_frame_not_the_one_under_the_cursor() {
s.mouse_button(ox, oy, "LeftButton", false);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **A window raised while part of it is hidden must keep its own children level with each other**
/// — the compaction may not split a sibling pair (director's report 2026-09-08, decision 2104).
///
/// This is Gatherer 1.0.0's Report window, built the way its XML builds it and reduced to the four
/// frames that matter:
///
/// - `Win` — `toplevel`, `frameStrata="DIALOG"`, born hidden, at level 1 (what `SetParent` gives a
/// child of a level-0 `UIParent`).
/// - `Close` — a Button declared inside `Win`'s `<Frames>`, so level 2, and visible with `Win`.
/// - `Body` — a `<Frame parent="Win" hidden="true" enableMouse="true">` covering the whole window
/// (`GathererInfo_ReportFrame`, 640×370 centred on a 640×390 dialog), also level 2, but HIDDEN
/// when `Win` is shown. The addon shows it one line later.
/// - `Other` — the already-open Options window it overlaps, which is what arms the occlusion gate.
///
/// The sequence is the addon's: `Win:Show()` (whose raise runs while `Body` is still hidden), then
/// `Body:Show()`. `Close` and `Body` are siblings under one parent, so their levels are equal by
/// construction and **must stay equal** — the reference's hit sweep then gives the tie to the
/// earlier-linked frame, which is `Close`, and the Close button works.
///
/// What went wrong: the compaction renumbered `Close` (visible, in the bucket) and not `Body`
/// (hidden, in no bucket), then the raise's propagate pushed the same delta into both — so the one
/// level the compaction squeezed out of `Close` and not out of `Body` became a permanent inversion.
/// `Body` is mouse-enabled and covers the window, so it swallowed every click and hover the Close
/// button should have had: the button showed no highlight and could not be pressed.
#[test]
fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
-- The window already on screen that `Win` has to raise over.
Other = CreateFrame("Frame", "Other")
Other:SetFrameStrata("DIALOG")
Other:SetFrameLevel(1)
Other:SetPoint("BOTTOMLEFT", 50, 50)
Other:SetSize(400, 400)
Other:EnableMouse(true)
Other:SetToplevel(true)
OtherKid = CreateFrame("Frame", "OtherKid", Other) -- level 2
Win = CreateFrame("Frame", "Win")
Win:SetFrameStrata("DIALOG")
Win:SetFrameLevel(1)
Win:SetPoint("BOTTOMLEFT", 200, 100)
Win:SetSize(400, 400)
Win:EnableMouse(true)
Win:SetToplevel(true)
Close = CreateFrame("Button", "Close", Win) -- level 2, visible with Win
Close:SetPoint("BOTTOMRIGHT", Win, "BOTTOMRIGHT", -15, 15)
Close:SetSize(100, 21)
Close:EnableMouse(true)
Close:SetScript("OnEnter", function(self) hovered = self:GetName() end)
Close:SetScript("OnClick", function(self) clicked = self:GetName() end)
Body = CreateFrame("Frame", "Body", Win) -- level 2, covers the whole window
Body:SetAllPoints(Win)
Body:EnableMouse(true)
Body:SetScript("OnEnter", function(self) hovered = self:GetName() end)
Body:Hide()
Win:Hide()
"#,
)
.unwrap();
s.resolve();
// The addon's own two lines: show the window (the raise fires here, with `Body` still hidden),
// then show the sub-frame for the selected tab.
s.run("Win:Show()").unwrap();
s.run("Body:Show()").unwrap();
s.resolve();
assert!(
level(&mut s, "Win") > level(&mut s, "Other"),
"the window that was just shown is in front: Win={} Other={}",
level(&mut s, "Win"),
level(&mut s, "Other")
);
// THE SYMPTOM FIRST, through the engine's own pointer path: the Close button takes its own
// click and its own hover. Asserted before the arithmetic below so a regression reports what
// the director would see, not the number behind it.
let (cx, cy) = centre(&s, "Close");
assert_eq!(
s.hit_test_name(cx, cy).as_deref(),
Some("Close"),
"the click at the Close button's centre lands on the Close button"
);
s.mouse_move(cx, cy);
assert_eq!(
s.eval::<String>("return tostring(hovered)").unwrap(),
"Close",
"and so does the hover — the highlight is the reference's OnEnter"
);
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
assert_eq!(
s.eval::<String>("return tostring(clicked)").unwrap(),
"Close"
);
// …and the number behind it.
assert_eq!(
level(&mut s, "Close"),
level(&mut s, "Body"),
"two children of one parent are level with each other — the raise's compaction may not \
split them just because one of them was hidden when it ran"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+46 -17
View File
@@ -125,39 +125,68 @@ impl WidgetArena {
}
}
/// Renumber `strata`'s **occupied** levels contiguously into `[0, count)` and return `count` —
/// `level_compact 0x764eb0`, the step `CSimpleTop::Raise 0x7650f0` runs immediately before it
/// sets the raised frame's level to `bucket->count(+0x8)` (wow-re `ui/scratch/toplevel-raise.md`,
/// consequence 1: the new level is the counter read *after* compaction, "never from a live
/// max-scan of frames").
/// Squeeze the free levels out of `strata` and return the level counter — `level_compact
/// 0x764eb0`, the step `CSimpleTop::Raise 0x7650f0` runs immediately before it sets the raised
/// frame's level to `bucket->count(+0x8)` (wow-re `ui/scratch/level-compact-law.md`, a §5 trio
/// dispatched from here; `ui/scratch/toplevel-raise.md` for the raise it serves).
///
/// **Only frames in the bucket are renumbered — i.e. effective-visible ones.** A stratum bucket
/// is an array of intrusive level lists, and a frame is linked into one only while it is
/// effectively visible (the same visible-gate [`WidgetArena::resequence_to_tail`] mirrors); a
/// hidden frame is in no bucket, so its `+0xc4` is not touched and it re-enters at whatever level
/// it kept.
/// **EVERY frame of the stratum is renumbered — hidden ones included, and that is the byte
/// law, not a benilla choice** (decision 2104). `0x764eb0`'s occupancy test is a two-part OR: a
/// level node's own list, else a scan of the client's **master frame list** (`[root+0xcc4]`,
/// link offset `0x304` — every live frame, joined in the `CSimpleFrame` ctor and counted by
/// `GetNumFrames`), and that scan filters on exactly two fields — `+0xc0` strata (`0x764f18`)
/// and `+0xc4` level (`0x764f20`). The renumber pass `0x764f80..0x764fc9` walks the same list.
/// A census of `+0xd4` (effectiveVisible) over the whole body `[0x764eb0, 0x76500e)` finds
/// **zero** hits; in `set_frame_level 0x76a4f0` the `+0xc4` write is unconditional
/// (`0x76a53b`) and `+0xd4` gates only the bucket relink below it (`0x76a541`).
///
/// Gating the renumber on visibility — which benilla did, reasoning from the level lists rather
/// than from the bytes — is not a relabelling at all: it changes the relative order of the part
/// it touches against the part it does not, and that difference then rides out on the raise's
/// `propagate` delta, which is computed in the *new* numbering and applied to levels still
/// carrying the *old* one. One level of squeeze taken out of a visible child and not out of its
/// hidden sibling becomes a permanent inversion between two frames that are level by
/// construction — `parent.level + 1` for both. Gatherer's Report window is the shape that found
/// it: the window's visible Close button and its hidden, mouse-enabled, full-cover body are both
/// direct children, the body was hidden for the Show raise, and it came out one level above the
/// button and ate every click and hover the button should have had. The reference lands that
/// pair equal, and the hit sweep's tie then goes to the earlier-linked frame, the button (1816).
///
/// So the visible gate stays where it belongs — on the *link list* (`resequence_to_tail`, draw
/// participation) — and off the *numbering*, which is a property of the frame.
///
/// **The renumber is strictly order-preserving, so by itself it changes no draw order**: distinct
/// levels map to distinct indices monotonically and equal levels stay equal. Its whole job is to
/// keep the raise target *bounded* — without it, `level := max + 1` would ratchet upward one step
/// per raise for as long as the session lasts.
///
/// Two things it deliberately is **not**: it does not propagate (every same-strata descendant is
/// itself in the bucket and is renumbered by its own level node), and it does not relink — the
/// client relocates whole level nodes with their intrusive lists intact, so link order (our
/// `insertion_seq`) must survive. That is why this writes `level` directly instead of going
/// through [`WidgetArena::set_frame_level`], which would do both.
/// **Ours packs tighter than the binary's, deliberately.** `bucket+0x8` is a high-water *bound*,
/// not a count of occupied levels, and `0x764eb0`'s cursor resumes at its saved pre-gap index
/// while that bound shrinks under it (`0x764fd3`/`0x764fd8`), so a wide gap carries an interior
/// hole past it: occupied `{0, 4, 6}` at count 7 comes out `{0, 1, 3}` at count 4, not
/// `{0, 1, 2}` at 3. That artifact is invisible to draw and hit order — both maps are monotone
/// and injective on levels, and both keep the invariant the raise needs (`count` above every
/// occupied level) — so we take the contiguous one rather than model a cursor bug.
///
/// Two things it deliberately is **not**: it does not propagate (`0x764faf` passes
/// `propagate = 0`, so a parent/child level *offset* is not preserved across a hole either —
/// parent 5 / child 7 comes out adjacent), and **it does not relink**, which is a known
/// divergence: the binary re-levels each moved frame through `0x76a4f0` at `0x764fb6`, so every
/// moved *visible* frame is unlinked and re-inserted and its within-level link order is
/// rewritten (a hidden frame takes no list churn). We write `level` directly instead, so our
/// `insertion_seq` survives a compaction untouched. Nothing observed yet turns on it; it is
/// named in 2104 rather than changed under a bug fix.
pub fn compact_levels(&mut self, strata: Strata) -> u16 {
let mut occupied: Vec<u16> = self
.iter_frames()
.filter(|(_, f)| f.effective_visible && f.strata == strata)
.filter(|(_, f)| f.strata == strata)
.map(|(_, f)| f.level)
.collect();
occupied.sort_unstable();
occupied.dedup();
let renumber: Vec<(FrameHandle, u16)> = self
.iter_frames()
.filter(|(_, f)| f.effective_visible && f.strata == strata)
.filter(|(_, f)| f.strata == strata)
.filter_map(|(h, f)| {
let idx = occupied.binary_search(&f.level).ok()? as u16;
(idx != f.level).then_some((h, idx))
+28 -4
View File
@@ -5,6 +5,12 @@ the version mlua 0.11 resolves to. Wired in through `[patch.crates-io]` in the w
## Why a fork exists at all
**benilla's Lua has to accept exactly the grammar the 1.12.1 client's Lua accepts — no more and no
less.** Every hunk below is that one rule: three restore 5.0 constructs 5.1 deleted, three delete
5.1 constructs 5.0 never had. A grammar difference in either direction is invisible to every
instrument we own until an addon trips over it, and the corpus *asks about* the difference
directly (decisions 1208, 2101).
The 1.12 addon corpus is Lua **5.0** code. It uses the iterator-less generic-for:
```lua
@@ -35,23 +41,41 @@ downgrade, and it is the entire reason this is a one-hunk fork rather than a ven
## The delta, and the command that proves it
**Current state: three hunks, in three files.** `src/lib.rs` additionally differs by having Lua
**Current state: six hunks, in three files.** `src/lib.rs` additionally differs by having Lua
5.2/5.3/5.4/5.5 stripped from the `Version` enum (with their source trees deleted) — benilla builds
5.1 and only 5.1, and a fork that still *offered* the other four would answer a request for one with
a missing directory at build time instead of a compile error.
**Three hunks restore what 5.1 removed:**
| file | hunk | why | record |
|---|---|---|---|
| `lvm.c` | 5.0's `OP_TFORPREP` table→`next` substitution, folded into the top of `OP_TFORLOOP` | `for k, v in someTable do` raised "attempt to call a table value"; it was the first session-start error for **60** corpus addons | 1215 |
| `luaconf.h` | `LUA_COMPAT_LSTR` `1` → `2` | 5.1 kept 5.0's nesting machinery and put an advisory error in front of it; two corpus addons died on "nesting of `[[...]]` is deprecated" | — |
| `lparser.c` | 5.0's compat-semicolon skip restored at the top of `constructor()`'s field loop — 5.0's own line, verbatim, comment included | one extra `;` after a field separator inside a table constructor (`Back_Title = AL["Factions"];;`, ×20 in AtlasLoot's `ButtonRegistry.lua`) parses on the client and died here with "unexpected symbol near `;`"; statement-level `;;` stays rejected by both dialects | 1315 |
**Three delete what 5.1 added** — all in `lparser.c`, all byte-read out of the client's own parser
(`simpleexp 0x6fd240`, `getunopr 0x6fe0a0`, `getbinopr 0x6fe0c0`), all landing on 5.0's own
`unexpected symbol` at `prefixexp 0x6fde40`:
| hunk | why | record |
|---|---|---|
| `simpleexp`'s `case TK_DOTS` deleted — `...` is no longer an expression | the Ace2 corpus **asks** which interpreter it is on by compiling `return function(...) return ... end`; 170 `loadstring` sites in the 219-addon corpus are that question and 92 library files gate on it. Answering `true` put Cartographer on its client-2.0 branch, hooking `CloseSpecialWindows` — a name 1.12 does not have. Also restores 5.0's rule that **every** vararg function gets its `arg` table | 1208, 2101 |
| `getunopr`'s `case '#'` deleted — no length operator | the client's `getunopr` tests exactly two tokens (`-`, `not`) and its `OPR_NOUNOPR` is **2**: a three-member enum, 5.0's. 5.0 asks a table with `table.getn` and a string with `string.len` | 2101 |
| `getbinopr`'s `case '%'` deleted — no modulo operator | the client's `getbinopr` switch is based at `'*'` (0x2A), so `%` (0x25) is below its range and reaches `OPR_NOBINOPR` = **14**: a fifteen-member `BinOpr`, 5.0's. 5.0 spells it `math.mod` | 2101 |
The ordering was deliberate and it held: the fork landed **inert**, byte-identical to upstream, so
the build pipeline was proven transparent *before* any behavioural payload went into it — and when
the suite then moved, the patch was the only thing that could have moved it. Both hunks are measured
against the 218-addon corpus rather than argued: the long-string one alone took loaded 134 → 139 and
the suite then moved, the patch was the only thing that could have moved it. Every hunk is measured
against the corpus rather than argued: the long-string one alone took loaded 134 → 139 and
survivors 99 → 104.
**The deletions are safe because nothing the reference runs uses those constructs, and that was
scanned rather than assumed** (2101 §4): a comment/string-stripping scan of the extracted 1.12
FrameXML (177 files), GlueXML, Blizzard's own addons and the director's installed AddOns folder
finds **zero** sites of all three. Our own engine Lua did use them and was rewritten into the 5.0
spellings; the rule now is that no chunk of ours may use a construct the client's parser rejects.
To verify the Lua sources against upstream at any time:
```sh
@@ -60,7 +84,7 @@ diff -r third_party/lua-src/lua-5.1.5 \
~/.cargo/registry/src/*/lua-src-550.0.0/lua-5.1.5
```
That must print **exactly** the three hunks in the table above and nothing else. If it ever prints
That must print **exactly** the six hunks in the tables above and nothing else. If it ever prints
more, something crept in.
## Status of the payload
+55 -12
View File
@@ -742,8 +742,8 @@ static void primaryexp (LexState *ls, expdesc *v) {
static void simpleexp (LexState *ls, expdesc *v) {
/* simpleexp -> NUMBER | STRING | NIL | true | false | ... |
constructor | FUNCTION body | primaryexp */
/* simpleexp -> NUMBER | STRING | NIL | true | false | constructor |
FUNCTION body | primaryexp */
switch (ls->t.token) {
case TK_NUMBER: {
init_exp(v, VKNUM, 0);
@@ -766,14 +766,39 @@ static void simpleexp (LexState *ls, expdesc *v) {
init_exp(v, VFALSE, 0);
break;
}
case TK_DOTS: { /* vararg */
FuncState *fs = ls->fs;
check_condition(ls, fs->f->is_vararg,
"cannot use " LUA_QL("...") " outside a vararg function");
fs->f->is_vararg &= ~VARARG_NEEDSARG; /* don't need 'arg' */
init_exp(v, VVARARG, luaK_codeABC(fs, OP_VARARG, 0, 1, 0));
break;
}
/* BENILLA: 5.1's vararg EXPRESSION arm, deleted (decision 2101; see
** third_party/lua-src/BENILLA.md). Lua 5.0 has no grammar for `...' as a
** value -- a vararg function reads its extra arguments out of the implicit
** `arg' table -- and `...' as an expression is the construct the Ace2 corpus
** uses to ASK which interpreter it is running:
**
** local lua51 = loadstring("return function(...) return ... end") and true or false
**
** 170 loadstring sites across the 219-addon corpus are that question, and 92
** library files in 24 folders gate on it (decision 1208). Answering it wrong
** makes every one of them take a client-2.0 branch on a 1.12 client -- which
** is how Cartographer came to hook `CloseSpecialWindows', a name 1.12 does
** not have.
**
** Byte-verified in the client itself, not inferred from stock 5.0. simpleexp
** is 0x6fd240; it switches on `token - 0x7B' bounded at 0xA3, indexes the
** byte table at 0x6fd338 and jumps through the 8-entry table at 0x6fd318.
** TK_DOTS is 0x118 (TK_NAME is 0x116 -- wow-5875-re system/ui/scratch/
** lua-dialect.md 9), so its index byte is 0x9D, whose value is 7: the DEFAULT
** arm, 0x6fd30a, which tail-jumps to primaryexp 0x6fdb00. The seven real arms
** are `{', TK_FALSE, TK_FUNCTION, TK_NIL, TK_TRUE, TK_NUMBER and TK_STRING --
** stock Lua 5.0's simpleexp exactly, with no vararg case.
**
** So `...' as a value reaches prefixexp 0x6fde40, whose head accepts only `('
** or TK_NAME and otherwise raises "unexpected symbol" (0x871e80) -- the same
** reject site lua-dialect.md 9 documents for a stray `;'. Deleting the arm
** reproduces that error here, verbatim.
**
** It also restores 5.0's `arg' rule for free: the deleted line cleared
** VARARG_NEEDSARG for any function that mentioned `...', so `arg' was not
** built for it. With the arm gone, NEEDSARG is never cleared and EVERY vararg
** function gets its `arg' table -- which is what 5.0 does and what all 177
** files of the shipped 1.12 FrameXML read. */
case '{': { /* constructor */
constructor(ls, v);
return;
@@ -796,7 +821,15 @@ static UnOpr getunopr (int op) {
switch (op) {
case TK_NOT: return OPR_NOT;
case '-': return OPR_MINUS;
case '#': return OPR_LEN;
/* BENILLA: 5.1's `#' length operator, deleted (decision 2101). 5.0 asks a
** table for its size with table.getn/getn and a string with string.len; the
** operator does not exist and neither does its __len metamethod.
** Byte-verified: getunopr is 0x6fe0a0, a leaf with exactly TWO token tests
** -- `cmp ecx,0x2D' (`-') then `cmp ecx,0x10E' (TK_NOT), then setne/inc -- so
** its OPR_NOUNOPR is 2, i.e. the enum has three members, not 5.1's four. The
** metamethod-name pool at 0x871896 agrees: no __len (wow-5875-re
** system/ui/scratch/lua-dialect.md 1, discriminator 4). A `#' therefore
** reaches simpleexp's default arm and raises "unexpected symbol near `#'". */
default: return OPR_NOUNOPR;
}
}
@@ -808,7 +841,17 @@ static BinOpr getbinopr (int op) {
case '-': return OPR_SUB;
case '*': return OPR_MUL;
case '/': return OPR_DIV;
case '%': return OPR_MOD;
/* BENILLA: 5.1's `%' modulo operator, deleted (decision 2101). 5.0 spells it
** math.mod (and the bare global `mod'), which this VM already publishes.
** Byte-verified: getbinopr is 0x6fe0c0 and its switch is based at `ecx-0x2A'
** -- `*', the LOWEST token it handles. `%' is 0x25, below the base, so it is
** outside the range test (`cmp eax,0xF2; ja') and falls to the default,
** 0x6fe129, `mov eax,0xE' = OPR_NOBINOPR 14 -- a 15-member BinOpr, 5.0's, not
** 5.1's 16-member one with OPR_MOD. Had 5.1's `%' case been present the
** switch would have been based at 0x25. The metamethod pool has no __mod
** either (wow-5875-re system/ui/scratch/lua-dialect.md 1, discriminator 4).
** priority[] is indexed by the BinOpr enum, which is untouched -- only the
** token that reaches OPR_MOD is gone. */
case '^': return OPR_POW;
case TK_CONCAT: return OPR_CONCAT;
case TK_NE: return OPR_NE;