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:
+12
-9
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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, ®ion, self_name, dbg);
|
||||
self.apply_region_layout(tr, ®ion, self_name, dbg, FontAttrs::Own);
|
||||
}
|
||||
|
||||
pub(super) fn apply_size(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
|
||||
|
||||
@@ -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(®ion_wrapper, "SetAlpha", a, dbg);
|
||||
}
|
||||
self.apply_region_layout(region, ®ion_wrapper, parent_name, dbg);
|
||||
self.apply_region_layout(
|
||||
region,
|
||||
®ion_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, ®ion_wrapper, parent_name, dbg);
|
||||
self.apply_region_layout(region, ®ion_wrapper, parent_name, dbg, FontAttrs::Own);
|
||||
self.apply_fontstring_font(region, ®ion_wrapper, dbg);
|
||||
self.apply_region_visual(region, ®ion_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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(®ion, "ClearAllPoints", (), dbg);
|
||||
this.apply_region_layout(t, ®ion, self_name, dbg);
|
||||
this.apply_region_layout(t, ®ion, self_name, dbg, FontAttrs::Own);
|
||||
if let Err(e) = crate::script::implicit_creation_anchor_lua(this.lua, ®ion) {
|
||||
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(®ion, "ClearAllPoints", (), dbg);
|
||||
self.apply_region_layout(bt, ®ion, self_name, dbg);
|
||||
self.apply_region_layout(bt, ®ion, self_name, dbg, font_attrs);
|
||||
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, ®ion) {
|
||||
self.report
|
||||
.errors
|
||||
@@ -560,7 +598,7 @@ impl Loader<'_> {
|
||||
if let Some(mode) = tt.attr("alphaMode") {
|
||||
self.call_region(®ion, "SetBlendMode", mode.to_string(), dbg);
|
||||
}
|
||||
self.apply_region_layout(tt, ®ion, self_name, dbg);
|
||||
self.apply_region_layout(tt, ®ion, self_name, dbg, FontAttrs::Own);
|
||||
if let Some(tc) = tex_coords_of(tt) {
|
||||
self.call_region(®ion, "SetTexCoord", tc, dbg);
|
||||
}
|
||||
@@ -634,7 +672,7 @@ impl Loader<'_> {
|
||||
if let Some(mode) = tt.attr("alphaMode") {
|
||||
self.call_region(®ion, "SetBlendMode", mode.to_string(), dbg);
|
||||
}
|
||||
self.apply_region_layout(tt, ®ion, self_name, dbg);
|
||||
self.apply_region_layout(tt, ®ion, self_name, dbg, FontAttrs::Own);
|
||||
if let Some(tc) = tex_coords_of(tt) {
|
||||
self.call_region(®ion, "SetTexCoord", tc, dbg);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Vendored
+28
-4
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user