plates take the mouse, Auctioneer loads, and the damage font is yours

- hovering a nameplate lights its unit and clicking one selects it, pfUI's click-through works, a drag that starts on a plate still turns the camera, and turning plates off with V clears them (2159)
- Auctioneer's XML documents open (the reference has no XML namespaces), a .toc line reaching Blizzard_AuctionUI's templates resolves for Auctioneer, BeanCounter and SpecialTalentUI, and a missing include logs rather than raising the red error dialog (2155)
- a .lua reached through <Script file=> carries the same Interface\AddOns\ chunk name as a .toc-listed one, so Ace2's debugstack split works and AtlasLoot keys its database; 26 more corpus addons load, 176 of 219 (2155)
- the floating combat numbers draw in whatever DAMAGE_TEXT_FONT holds at world entry, so MikScrollingBattleText and pfUI pick the font (2156); a dodged swing prints no line and floats no word, and happiness leech lines print (2157)
- an unknown frameStrata in XML logs and keeps the default instead of raising, so EQL3's log window loads; FrameXML_Debug is a real switch, so ImprovedErrorFrame loads (2160)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:56:00 +07:00
co-authored by Claude Fable 5.1
parent 38e45badb9
commit 6af2bfcb73
32 changed files with 2036 additions and 346 deletions
@@ -383,6 +383,18 @@ fn main() {
" loaded without a single load error : {loaded}/{}",
reports.len()
);
// **The reconciliation line, printed always** (decision 2155). `loaded` counts what RAISED;
// a manifest entry naming a file the package does not contain is not that — the reference logs
// `Couldn't open %s` and carries on — and it used to be counted here. Every past record's
// figure was the stricter one, so the stricter one is printed beside the honest one rather
// than left for a reader to reconstruct: two numbers cannot be silently confused, one can.
let strict = reports.iter().filter(|r| r.errors.is_empty()).count();
println!(
" (…{} of those name a file their own package does not contain, which the reference \
logs and carries on from; the pre-2155 column counted those as failures: {strict}/{})",
loaded - strict,
reports.len()
);
println!(
" ...and calling nothing we lack : {clean}/{}",
reports.len()
+118 -40
View File
@@ -107,7 +107,19 @@ pub struct AddonReport {
/// `## Interface`, as written. 1.12 is `11200`; the corpus is full of older values, and we
/// deliberately do not refuse them (decision 1191 §6).
pub interface: Vec<u32>,
/// Did every file in its manifest load without an error?
/// **Did anything in its manifest RAISE, fail to parse, or drop a frame?**
///
/// Not "did every entry resolve" — that is [`Self::absent_own_files`] and
/// [`Self::absent_foreign_files`], and it is a different question because the reference
/// answers it differently: a manifest line naming no file is `"Couldn't open %s"` in
/// FrameXML.log and the walk carries straight on (wow-re
/// `ui/scratch/xml-toc-path-resolution.md` §4, VERIFIED). An addon shipping an incomplete zip
/// *works* on a real client, so counting it here made the survey say the opposite of the truth
/// about 24 addons — FuBar itself among them, on two locale files it does not ship.
///
/// **[`Self::errors`] is unchanged and still carries those rows verbatim** (1213), so every
/// number in every past record is still readable off it; what changed, deliberately and with
/// decision 2155 behind it, is which of them this flag counts. The report prints both.
pub loaded: bool,
/// Load errors, verbatim, tagged by file.
pub errors: Vec<String>,
@@ -577,7 +589,7 @@ fn survey_one(
// makes a high-water mark a valid cut of a log that also evicts and dedupes.
let warn_mark = script.diagnostics().last().map_or(0, |d| d.seq);
let (errors, absent) = load_addon_files(&script, root, name, &toc);
let files = 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
@@ -647,10 +659,10 @@ fn survey_one(
AddonReport {
name: name.to_string(),
interface: toc.interface_versions(),
loaded: errors.is_empty(),
errors,
absent_own_files: absent.own,
absent_foreign_files: absent.foreign,
loaded: !files.raised,
errors: files.errors,
absent_own_files: files.absent.own,
absent_foreign_files: files.absent.foreign,
missing_globals: wants.missing_globals,
missing_deps,
missing_templates,
@@ -1427,10 +1439,11 @@ fn missing_inherits(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> Ve
/// tree hanging off them. An addon's real Lua often hangs off its XML rather than its `.toc`, the
/// same trap the 1.12 corpus set in decision 1190.
fn source_files(root: &Path, name: &str, toc: &Toc) -> Vec<String> {
let base = addon_base(name);
let mut pending: Vec<String> = toc
.files
.iter()
.map(|f| benilla_ui::loader::join_ref(name, f))
.map(|f| benilla_ui::loader::join_ref(&base, f))
.collect();
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut out = Vec::new();
@@ -1864,23 +1877,40 @@ pub struct AbsentFiles {
pub foreign: Vec<String>,
}
/// What one addon's manifest did: every load failure verbatim, which of them were files that
/// simply are not there, and whether anything actually **raised**.
struct FileLoad {
/// Every load failure, in order, unchanged in content and meaning (1213: `errors` is never
/// quietly shrunk — every past record's number is still readable off this list).
errors: Vec<String>,
/// The subset of [`Self::errors`] that is a named file the provider does not have, split by
/// whose package is incomplete.
absent: AbsentFiles,
/// **Did anything raise, fail to parse, or drop a frame** — the question
/// [`AddonReport::loaded`] asks (decision 2155).
///
/// It is `false` for an addon whose only failures are absent files, because on the reference
/// that addon *loads*: `0x6edaa0` logs `"Couldn't open %s"` and returns null, and the walk
/// carries on to the next manifest entry with nothing raised (wow-re
/// `ui/scratch/xml-toc-path-resolution.md` §4, VERIFIED — the same rule 2107 unified for the
/// two loaders that reach it from Lua). Until this existed the survey scored 24 corpus addons
/// as not surviving a session start on nothing but a locale file their own zip omits, FuBar
/// and 22 of its plugins among them.
raised: bool,
}
/// Run the addon's manifest through the same two arms the real loader uses — `.lua` as a chunk,
/// anything else as FrameXML — with the AddOns root as the provider's path space (decision 1186).
///
/// Returns the errors **and**, beside them, the split of which entries did not resolve. The
/// absent ones are still in `errors` — 1213's rule, for the fifth time: this asks a new question
/// and gets a new column, it does not quietly shrink an old one.
fn load_addon_files(
script: &UiScript,
root: &Path,
name: &str,
toc: &Toc,
) -> (Vec<String>, AbsentFiles) {
/// anything else as FrameXML — in the client's **install-relative** path space (decision 2155:
/// `Interface/AddOns/<Folder>`, exactly what `ui_script::addons::Addon::prefix` hands its own
/// loader, so a chunk name here and a chunk name in a live session are the same string).
fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad {
let provider = |req: &str| -> Option<Vec<u8>> { read_under(root, req) };
let base = addon_base(name);
let mut errors = Vec::new();
let mut absent = AbsentFiles::default();
let mut raised = false;
for file in &toc.files {
let path = benilla_ui::loader::join_ref(name, file);
let path = benilla_ui::loader::join_ref(&base, file);
let Some(bytes) = read_under(root, &path) else {
errors.push(format!("{file}: not found"));
// WHOSE package is incomplete. `join_ref` has already collapsed the `..`s the way the
@@ -1889,7 +1919,7 @@ fn load_addon_files(
// inside the addon's own folder is the addon shipping a manifest it does not satisfy,
// while one pointing out of it wants a neighbour that is not installed.
let own = path
.strip_prefix(name)
.strip_prefix(base.as_str())
.is_some_and(|rest| rest.starts_with('/'));
if own {
absent.own.push(file.clone());
@@ -1901,11 +1931,13 @@ fn load_addon_files(
if is_lua(file) {
// Named as the client names it, so the survey sees what a player would: an addon
// that PARSES a traceback for its own folder (the whole FuBar family) needs the real
// `Interface\AddOns\<Folder>\<File>` shape, not mlua's Rust-caller default.
if let Err(e) =
script.run_chunk_named(&bytes, &benilla_ui::script::addon_chunk_name(name, file))
// `Interface\AddOns\<Folder>\<File>` shape, not mlua's Rust-caller default. Built
// from the RESOLVED path, like the loader's own `<Script file=>` naming — one rule,
// so the two doors into a VM cannot disagree about what a file is called (2155).
if let Err(e) = script.run_chunk_named(&bytes, &format!("@{}", path.replace('/', "\\")))
{
errors.push(format!("{file}: {e}"));
raised = true;
}
continue;
}
@@ -1918,12 +1950,39 @@ fn load_addon_files(
for w in report.warnings {
script.report_warning(&format!("{file}: {w}"));
}
// A file the DOCUMENT named and the provider does not have — the `.toc` arm's rule
// one level down (decision 2155). Recorded as absent, under the same own/foreign
// split, and never as something that raised: the reference logs `Couldn't open %s`
// for an `<Include>` and `Error loading %s` for a `<Script file=>`, and carries on.
for m in report.missing_files {
let own = m
.split_once("no provider hit for \"")
.and_then(|(_, rest)| rest.split_once('"'))
.is_some_and(|(p, _)| {
p.strip_prefix(base.as_str())
.is_some_and(|r| r.starts_with('/'))
});
errors.push(format!("{file}: {m}"));
if own {
absent.own.push(format!("{file}: {m}"));
} else {
absent.foreign.push(format!("{file}: {m}"));
}
}
raised |= !report.errors.is_empty();
errors.extend(report.errors.into_iter().map(|e| format!("{file}: {e}")));
}
Err(e) => errors.push(format!("{file}: {e}")),
Err(e) => {
errors.push(format!("{file}: {e}"));
raised = true;
}
}
}
(errors, absent)
FileLoad {
errors,
absent,
raised,
}
}
fn is_lua(entry: &str) -> bool {
@@ -1935,7 +1994,14 @@ fn is_lua(entry: &str) -> bool {
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("lua"))
}
/// `root/rel`, refusing to escape — the same lexical AddOns-root sandbox the loader applies.
/// One addon file, read **through the client's own reader** — the loose AddOns tree, then the
/// player's patch chain ([`crate::ui_script::addons::read_addon_file`], decision 2155).
///
/// It delegates rather than re-implementing, and that is the whole point of it still existing as a
/// name: this was a private copy of the loader's `read_under`, and a copy of a resolution rule is
/// a copy that drifts. It had already drifted — the client resolves `..\Blizzard_AuctionUI\…`
/// and `..\..\FrameXML\…` off the chain and this could not, so the survey reported two addon
/// families as broken packages when the miss was the instrument's.
///
/// **Bytes, like the loader's** (decision 1193). Until then this function carried a private
/// lossy-UTF-8 + BOM-strip of its own, so the harness could survey files the *client* refused to
@@ -1943,14 +2009,16 @@ fn is_lua(entry: &str) -> bool {
/// round. The client reads bytes now, so the harness can simply read bytes too, and the one place
/// that still needs text ([`read_text`]) says so.
fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
let rel = Path::new(rel);
if rel
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
return None;
}
std::fs::read(root.join(rel)).ok()
crate::ui_script::addons::read_addon_file(root, rel)
}
/// Where one addon's files sit in the install's path space — `Interface/AddOns/<Folder>`.
///
/// The survey's copy of [`crate::ui_script::addons::Addon::prefix`], and it must stay its copy:
/// every path this module builds is handed to the same reader and named by the same rule, so a
/// divergence here is a survey measuring a client nobody runs.
fn addon_base(name: &str) -> String {
format!("Interface/AddOns/{name}")
}
/// [`read_under`] for the **source scanner**, which greps text rather than running it.
@@ -3908,9 +3976,11 @@ mod dependency_tests {
let wants = of("WantsNeighbour");
assert_eq!(
wants.absent_foreign_files,
vec!["NotInstalled/templates.xml".to_string()],
vec!["Interface/AddOns/NotInstalled/templates.xml".to_string()],
"`..` is collapsed the way the client collapses it, and the RESOLVED path is what is \
reported — the collapse is the interesting half"
reported — the collapse is the interesting half. The path is INSTALL-relative since \
2155, which is the space the reference's file layer is actually handed: this exact \
shape is Auctioneer's, and its real neighbour resolves off the chain now."
);
assert!(
wants.absent_own_files.is_empty(),
@@ -3918,13 +3988,21 @@ mod dependency_tests {
wants.absent_own_files
);
// NOTHING is subtracted. Both are still load failures, still in `errors`, still counted
// in every headline — the split is a new column beside them, never a quieter old one.
// **NOTHING is subtracted from `errors`** (1213) — both rows are still there, verbatim, so
// every figure any past record quoted is still readable off this list. What changed in
// 2155 is which of them `loaded` counts: on the reference these addons LOAD, because
// `0x6edaa0` logs `Couldn't open %s` and the walk carries on with nothing raised
// (`ui/scratch/xml-toc-path-resolution.md` §4). Both halves are asserted together,
// because either one alone is a rule that has already been got wrong in both directions.
for r in [short, wants] {
assert!(!r.loaded, "{}: still a load failure", r.name);
assert!(
r.loaded,
"{}: a file the package does not contain is not something that RAISED",
r.name
);
assert!(
r.errors.iter().any(|e| e.contains("not found")),
"{}: the error is still there verbatim: {:?}",
"{}: …and the row is still there verbatim: {:?}",
r.name,
r.errors
);
@@ -107,7 +107,7 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome>
&mut dep_order,
);
let (load_errors, _absent) = super::load_addon_files(&script, root, name, &toc);
let load_errors = super::load_addon_files(&script, root, name, &toc).errors;
// 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.
+58 -3
View File
@@ -47,8 +47,13 @@
//! first reading hardcoded its 4:3 value 0.6 — see [`text_px`]), the round being the gx
//! `ScreenToPixelHeight` law (`0x5c6fa0`, wow-re `crates/font/src/screen_pixel.rs`, bit-exact
//! difftested). At 1024×768 (diag 1280): normal number 23 px, crit settles 35 px, pop peaks ~70 px.
//! The font is **DAMAGE_TEXT_FONT** (`0x6c8470` reads the FrameScript global; shipped Fonts.xml:
//! `Fonts\FRIZQT__.TTF` — our atlas default), created flags 0 (`6c8498 xor edx,edx`): no outline.
//! The font is **whatever the Lua global `DAMAGE_TEXT_FONT` holds** when `0x6c8470` reads it —
//! see [`DamageTextFont`] for the binding law (a plain `lua_gettable`, evaluated once, at the tail
//! of the world-entry UI load, i.e. after every addon's `ADDON_LOADED`). `Fonts\FRIZQT__.TTF` is
//! only the value the shipped `Fonts.xml` assigns, not a default the engine knows; there is in
//! fact **no fallback face at all** in the reference — a global that resolves to nothing yields a
//! NULL handle and the combat text simply does not draw. Created flags 0 (`6c8493 xor edx,edx` —
//! not `6c8498`, which is `mov ecx,esi`): no outline.
//!
//! **The ALPHA + SHADOW law** (`time_alpha_fade 0x6c82e0` — wow-re
//! `playername/scratch/worldtext-alpha-shadow-law.md`, §5 pair + the emulated bit-exact difftest,
@@ -153,6 +158,42 @@ struct WorldText {
#[derive(Resource, Default)]
pub(crate) struct WorldTexts(Vec<WorldText>);
/// **The face the floating numbers draw in — the Lua global `DAMAGE_TEXT_FONT`, resolved once per
/// world session** (decision 2156).
///
/// `0x6c8470` reads the global's *value* eagerly and hands it straight to the font factory:
/// `6c847c mov ecx,0x86c9ac` ("DAMAGE_TEXT_FONT") → `0x703bf0` `FrameScript_GetText`'s fast arm
/// (ordinal −1, gender 0) → `0x704350` `GetGlobalString`, which is a plain
/// `lua_gettable(LUA_GLOBALSINDEX)` — the string is read *now*, not deferred, and the returned
/// pointer is a borrowed `const char*` into the Lua TString, consumed before the call returns.
/// `0x6c847c` is the **only** instruction in the image that reads this global.
///
/// **When** is the whole finding. `0x6c8470` is not CRT init, as three wow-re notes had it — it
/// runs from `0x401570 + 0x1620`, *after* the UI load `0x401602`, so after FrameXML has run
/// `Fonts.xml` and after every non-LoadOnDemand addon's `ADDON_LOADED`. That is exactly where
/// MikScrollingBattleText (`MikScrollingBattleText.lua:255`) and pfUI (`pfUI.lua:179`) assign it,
/// and it is why their assignment reaches the real client's damage numbers.
///
/// And it is **once**: the handle `[0xce8820]` has exactly one writer image-wide, there is no
/// invalidation hook and no per-string re-resolution, and `/reloadui` does **not** re-run
/// `0x6c8470` (it only sets the deferred flag `[0xb4b3f4]`, whose reader rebuilds the UI through
/// `0x48fbf0` — a different function). So an assignment made after world-enter lands at the next
/// world-enter and not before. Hence a resource seated on the load edge rather than a global read
/// per string.
#[derive(Resource, Default)]
pub(crate) struct DamageTextFont(pub(crate) Option<String>);
/// Read `DAMAGE_TEXT_FONT` out of the VM — the `0x6c8481` call, at the moment the reference makes
/// it (the tail of the world-entry UI load; see [`DamageTextFont`]).
///
/// `0x704350` accepts a Lua **string or number** (`lua_isstring`) and reports failure by leaving
/// `0x703bf0`'s pre-seeded `""` in place, so an absent, nil or non-string global reads as empty —
/// which is the same thing the factory's own name guard rejects. Empty folds to `None` here.
pub(crate) fn read_damage_text_font(script: &benilla_ui::script::UiScript) -> DamageTextFont {
let value: Option<String> = script.lua().globals().get("DAMAGE_TEXT_FONT").ok();
DamageTextFont(value.filter(|v| !v.is_empty()))
}
/// The client's per-unit slot count (`PLAYERNAMEDESC` +0x20..+0x2c): a 5th concurrent text over
/// one unit is dropped outright.
const MAX_PER_UNIT: usize = 4;
@@ -196,6 +237,9 @@ pub(crate) fn float_combat_text(
// The frame's claim bucket 1 (worldtext) — cleared and rebuilt every pass; plates own
// bucket 0 in `vplates` and the two never interact.
mut bucket: Local<crate::smart_rect::SmartBucket>,
// The face, bound once on the world-entry load edge ([`DamageTextFont`]). Absent in a bare
// test world, where the fallback face is what the reference's stock `Fonts.xml` names anyway.
font: Option<Res<DamageTextFont>>,
) {
let now = time.elapsed_secs_f64();
// Headless (captures/tests) the camera is None — spawns/expiry still run, nothing draws.
@@ -314,7 +358,10 @@ pub(crate) fn float_combat_text(
},
Z_WORLD_TEXT,
FontSpec {
path: None, // DAMAGE_TEXT_FONT = Friz Quadrata (the default face), no outline
// The bound face, and NOT a hardcoded Friz: `Fonts\\FRIZQT__.TTF` is only what
// the stock `Fonts.xml` happens to assign, and an addon that assigns something
// else before the load edge closes is what the reference draws.
path: font.as_ref().and_then(|f| f.0.as_deref()),
height: Some(target_px),
outline: Outline::None,
alpha_gradient: None,
@@ -424,6 +471,13 @@ fn melee_impact_text(
mut text: MessageWriter<CombatTextSpawn>,
) {
for crate::creature_anim::SwingImpact { swing: s, .. } in impacts.read() {
// `0x62440d` — the FIRST thing `0x6243e0` does, before the self gate and before the
// colour law: a swing from a spell that did not land plainly floats nothing. It is the
// same `0x625e40` verdict that suppresses the chat line, and it stops here and nowhere
// else — the flinch, the blood and the impact sounds all still fire.
if !s.displayed {
continue;
}
let Some(victim) = s.victim else { continue };
if self_player.contains(victim) {
continue; // Gate A: never over your own head
@@ -464,6 +518,7 @@ impl Plugin for CombatTextPlugin {
// The Update append window (see [`UiQuadAppend`]): after the camera controller, and
// projecting through the camera's FRESH Transform (not the stale propagated global).
app.init_resource::<WorldTexts>()
.init_resource::<DamageTextFont>()
.init_resource::<DamageTextGates>()
.add_message::<CombatTextSpawn>()
.add_systems(
+6
View File
@@ -645,6 +645,12 @@ pub(crate) struct SwingMessage {
/// 7 immune · 8 deflects (decision 0279's byte-verified consequence table keys off it).
pub(crate) victim_state: u32,
pub(crate) damage: u32,
/// `0x625e40`'s verdict, carried from the packet because **the floating number is gated by it
/// too** ([`benilla_protocol::messages::AttackerState::displayed`]): `0x62440d` is the first
/// thing the worldtext builder `0x6243e0` does. Everything else this message drives — the
/// animation, the flinch, the blood, the sounds, the timers — runs regardless, which is why
/// the verdict rides along instead of suppressing the message.
pub(crate) displayed: bool,
/// [`PlaySeq`] stamp at emission (the wire drain, in packet order).
pub(crate) seq: u64,
}
@@ -766,6 +766,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
hit_info: 0,
victim_state: 2, // dodge — the whiff class
damage: 0,
displayed: true,
seq: 2,
});
app.update();
@@ -842,6 +843,7 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
hit_info: 0x2,
victim_state: 1,
damage: 21,
displayed: true,
seq: 1,
});
app.world_mut().write_message(EmoteAnim {
@@ -861,6 +863,7 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
hit_info: 0x2,
victim_state: 1,
damage: 21,
displayed: true,
seq: 4,
});
app.update();
@@ -1003,6 +1006,7 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() {
hit_info: 0x2,
victim_state: 1,
damage: 21,
displayed: true,
seq: 1,
});
app.update();
@@ -1039,6 +1043,7 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() {
hit_info: 0x2,
victim_state: 1,
damage: 21,
displayed: true,
seq: 2,
});
app.update();
@@ -2109,6 +2114,7 @@ fn a_midair_deferred_park_survives_the_level_and_dies_at_the_landing_play() {
hit_info: 0x2,
victim_state: 1,
damage: 21,
displayed: true,
seq: 2,
});
app.update();
@@ -2239,6 +2245,7 @@ fn a_landed_swing_snaps_its_attacker_out_of_the_ranged_stance() {
hit_info: 0,
victim_state: 1,
damage: 7,
displayed: true,
seq: 0,
});
app.update();
@@ -3682,6 +3689,7 @@ fn a_disarmed_attacker_swings_and_stands_unarmed() {
hit_info,
victim_state: 1,
damage: 7,
displayed: true,
seq,
});
}
@@ -3762,6 +3770,7 @@ fn an_off_hand_only_fighter_is_the_case_that_punches_off_hand() {
hit_info: 0x4,
victim_state: 1,
damage: 7,
displayed: true,
seq: 1,
});
app.update();
@@ -4165,6 +4174,7 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() {
hit_info: 0x2,
victim_state: 1,
damage: 21,
displayed: true,
seq: 2,
});
app.update();
@@ -286,6 +286,7 @@ mod tests {
hit_info: 0x2,
victim_state,
damage: if victim_state == 1 { 42 } else { 0 },
displayed: true,
seq: 1,
}
}
@@ -169,6 +169,7 @@ pub(super) fn attacker_state(
hit_info: s.hit_info,
victim_state: s.victim_state,
damage: s.damage,
displayed: s.displayed(),
seq,
};
if let Some(&e) = index.0.get(&s.attacker) {
+382 -88
View File
@@ -91,6 +91,23 @@ impl ChatCtx<'_> {
}
Some(display.name.clone())
}
/// `0x6ea280 == 2` — **"this spell targets enemies"**, the predicate the chat-type stubs
/// `0x627d30`/`0x627d60` run to choose between a family's `…_DAMAGE` and `…_BUFF` rows
/// (§2.3). It reads the row, never the endpoints, which is why it lives beside
/// [`Self::spell_name`] and not beside the classifier.
///
/// A row the catalog cannot answer reads as **harmful**, which is not a claim about the
/// spell — it is our own data gap, and the reference never reaches the question (it has
/// already bailed on a missing record at the `SpellRec+0x18` gate above, where we deliberately
/// carry on: see [`Self::spell_name`]). Harmful is chosen because it is the `…_DAMAGE` row
/// every one of these sites used unconditionally before the consult existed, so a missing row
/// keeps the behaviour it had rather than acquiring a new one.
fn spell_harmful(&self, spell_id: u32) -> bool {
self.spells
.and_then(|s| s.catalog.get(spell_id))
.is_none_or(benilla_formats::SpellDisplay::is_harmful)
}
}
/// `SMSG_ATTACKERSTATEUPDATE` → the melee line. The whole `COMBAT_*` half of the block comes from
@@ -105,9 +122,20 @@ pub(super) fn attacker_state(
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
// `0x625e40`, the first of the packet's two whole-display gates and the one that runs before
// the dispatcher is even entered (`0x629b3e` → `0x629b45 je`): a swing that came from a spell
// and did not land plainly emits **no line at all**. The floating number is gated by the same
// predicate at its own site.
if !s.displayed() {
return;
}
let attacker = ctx.classify(s.attacker, stores);
let victim = ctx.classify(s.victim, stores);
let family = combat::melee_family(s.hit_info, s.victim_state, s.damage, s.school);
// `0x62a710`'s else-arm declines for VictimState 0/1/4/9 — the reference emits nothing at all
// for a swing that landed in one of those states, so neither does the packet.
let Some(family) = combat::melee_family(s.hit_info, s.victim_state, s.damage, s.school) else {
return;
};
// A swing is a MISS line only for the outcomes the reference words as one; everything the
// victim did about it (dodge, parry, block, …) is still a "misses" chat TYPE, because the type
// pair is hits/misses and only a landed hit is "hits".
@@ -451,35 +479,33 @@ pub(super) fn periodic_aura_log(
..Default::default()
},
),
// A mana leech is one sentence about two transfers: what the victim lost and what the
// caster gained. vmangos sends the drained amount and a multiplier, not the gain, so
// the gained figure is `amount * multiplier` — the same product the server applies.
//
// **Both figures are wire-raw** and the shared leech/drain formatter `0x627930` states
// the arithmetic exactly (decision 2117): `drained = amount / div`, `gained =
// trunc(amount·multiplier) / div` — the product truncates first, then divides — and a
// `drained` of zero drops the line, which is the reference's own test and the reason
// the periodic arm needs it as much as the execute-log one does.
// **Aura 64 is not an arm of this match at all.** It reaches the very same function
// the execute-log's `POWER_DRAIN` effect does — `0x627910` → `0x627930`, with the
// periodic flag set — so every choice that function makes (the LEECH/DRAIN fork, the
// happiness arm and its fall-through, the out-of-range drop, the chat-type stub)
// belongs to it and not to a constant written out here. This arm used to word
// `SPELLPOWERLEECH` unconditionally against an execute-log arm that forked three ways:
// one formatter, two call sites that disagreed (2117's named residue, closed by 2157).
PeriodicTick::ManaLeech {
power,
amount,
multiplier,
} => {
let Some((drained, gained)) = leech_figures(power, amount, multiplier) else {
continue;
};
(
combat::SPELLPOWERLEECH,
power_drain_line(
log,
ctx,
stores,
poses,
&spell,
s.spell_id,
(s.caster, caster),
(s.target, target),
power,
periodic_leech_amount(power, amount),
multiplier,
true,
Fills {
spell: spell.clone(),
power: Some(power),
amount: drained,
amount2: gained,
power2: Some(power),
..Default::default()
},
)
);
continue;
}
};
let Some(kind) = combat::periodic_kind(periodic_subject(tick, caster, target), buff) else {
@@ -633,7 +659,12 @@ pub(super) fn spell_outcome_log(
let Some(spell) = ctx.spell_name(s.spell_id) else {
return;
};
let Some(kind) = combat::spell_kind(caster, target, false) else {
// The two sentences do not share a selector, and this is where that bites: `PROCRESIST` goes
// through the damage/buff stub `0x627d30`, which asks the SPELL (`0x6ea280 == 2`), while
// `IMMUNESPELL` goes straight to the plain damage selector `0x626be0`. So an immunity to a
// *helpful* spell still files under `…_DAMAGE`, and a resisted helpful proc does not.
let buff = !immune && !ctx.spell_harmful(s.spell_id);
let Some(kind) = combat::spell_kind(caster, target, buff) else {
return;
};
queue(
@@ -704,13 +735,15 @@ pub(super) fn dispel_failed(
) {
let caster = ctx.classify(s.caster, stores);
let victim = ctx.classify(s.victim, stores);
let Some(kind) = combat::spell_kind(caster, victim, false) else {
return;
};
for &spell_id in &s.spell_ids {
let Some(spell) = ctx.spell_name(spell_id) else {
continue;
};
// Per line, because `0x627d30` consults the spell and the spells differ — which is what
// this arm's doc has said since it was written, over a `false` hoisted out of the loop.
let Some(kind) = combat::spell_kind(caster, victim, !ctx.spell_harmful(spell_id)) else {
continue;
};
queue(
log,
ctx,
@@ -825,10 +858,10 @@ pub(super) fn spell_log_execute(
for (_effect, rows) in &s.effects {
for row in rows {
match *row {
// Effect 8 POWER_DRAIN. Three families come out of this one row: happiness has no
// `…_POINTS` noun so it gets its own sentence at the misc-info type, a real leech
// multiplier says the caster GAINED what the target lost, and a zero one is a plain
// drain. `drained == 0` drops the line — the reference's own test.
// Effect 8 POWER_DRAIN — the non-periodic entry to the shared formatter
// [`power_drain_line`] (`0x627930` with the periodic flag clear). Every family
// choice this row can make lives there, because the periodic packet makes the
// same ones through the same function.
E::PowerDrain {
target,
amount,
@@ -836,58 +869,19 @@ pub(super) fn spell_log_execute(
multiplier,
} => {
let victim = ctx.classify(target, stores);
let Some((drained, gained)) = leech_figures(power, amount, multiplier) else {
continue;
};
if power == POWER_HAPPINESS {
// `0x627de0`: the subject is the pet's OWNER and the named thing is the
// pet, at the literal misc-info type.
let Some(owner) = unit_owner(target, ctx, stores) else {
continue;
};
let owner_class = ctx.classify(owner, stores);
queue_named(
log,
ctx,
poses,
crate::ui_chat::ChatEventKind::CombatMiscInfo,
combat::SPELLHAPPINESSDRAIN,
(owner, owner_class),
(target, victim),
Fills {
amount: drained,
..Default::default()
},
combat::Named::Unit(target),
);
continue;
}
let Some(kind) = combat::spell_kind(caster, victim, false) else {
continue;
};
// `|multiplier| >= 2^-22` is the reference's own epsilon (`[0x8029d4]`), not a
// round number of ours.
let leech = multiplier.abs() >= LEECH_EPSILON;
queue(
power_drain_line(
log,
ctx,
stores,
poses,
kind,
if leech {
combat::SPELLPOWERLEECH
} else {
combat::SPELLPOWERDRAIN
},
&spell,
s.spell_id,
(s.caster, caster),
(target, victim),
Fills {
spell: spell.clone(),
power: Some(power),
power2: Some(power),
amount: drained,
amount2: gained,
..Default::default()
},
power,
amount,
multiplier,
false,
);
}
// Effect 19 ADD_EXTRA_ATTACKS. **The caster guid is never passed** (`0x62d9f0`):
@@ -1073,6 +1067,10 @@ fn power_gain(power: u32, amount: u32) -> i64 {
/// (`SPELLPOWERLEECH`/`…DRAIN`) hand it `outClassA`, the **caster** (`0x6275fb`,
/// `0x627a0f`/`0x627a3a`). A new tick kind has to answer this question rather than inherit an
/// answer.
///
/// The `ManaLeech` row is the table's record of the fourth formatter and is what the test pins;
/// the tick itself is worded by [`power_drain_line`], which applies the same row directly (it is
/// handed the caster and the target, not the tick, because its other call site has no tick).
fn periodic_subject(tick: &PeriodicTick, caster: UnitClass, target: UnitClass) -> UnitClass {
match tick {
PeriodicTick::Damage { .. } | PeriodicTick::Heal { .. } => target,
@@ -1080,24 +1078,216 @@ fn periodic_subject(tick: &PeriodicTick, caster: UnitClass, target: UnitClass) -
}
}
/// Arm 5 of `0x627930`: **a real multiplier says the caster gained what the target lost**.
///
/// `6279ff fcomp dword ptr [0x8029d4]` against `|multiplier|`, and `[0x8029d4]` is `2^-22`
/// (`00 00 80 34`) — the reference's own epsilon, not a round number of ours. The compare's `jp`
/// takes the greater-or-equal side, so **equality is a leech**; only a strictly smaller magnitude
/// is the plain `SPELLPOWERDRAIN`.
fn leech_family(multiplier: f32) -> Family {
if multiplier.abs() >= LEECH_EPSILON {
combat::SPELLPOWERLEECH
} else {
combat::SPELLPOWERDRAIN
}
}
/// The `(drained, gained)` pair a leech/drain sentence words, or `None` when the line is dropped.
///
/// One function because the reference has one formatter: `0x627930` serves both
/// `SMSG_SPELLLOGEXECUTE` effect 8 `POWER_DRAIN` and `SMSG_PERIODICAURALOG` aura 64, and the three
/// rules are its own — `drained = amount / div`, `gained = trunc(amount·multiplier) / div` (the
/// product truncates *before* the divide, which is why this is arithmetic and not two divides),
/// and **`drained == 0` drops the line**.
/// rules are its own — `drained = amount / div`, `gained = round(amount·multiplier) / div` (the
/// product is reduced to an integer *before* the divide, which is why this is arithmetic and not
/// two divides), and **`drained == 0` drops the line** (`627ae3 test esi,esi`).
///
/// **The two figures do not share their arithmetic**, which 2117 recorded as one `trunc`.
/// `drained` is a plain signed integer divide of the wire amount (`627ad9 idiv`), no FPU at all.
/// `gained` goes through the x87: the product is doubled, biased by ∓0.5 on its own sign,
/// `fistp`-ed under the default round-half-to-even, and arithmetic-shifted back down
/// (`627a8f`..`627ac9`) — a hand-rolled rounding that, for the non-negative products a leech
/// actually produces, lands on exactly `floor(p)`, i.e. the truncation the record claimed. So the
/// figures are unchanged and the *mechanism* named here is the byte's, not a paraphrase of it.
fn leech_figures(power: u32, amount: u32, multiplier: f32) -> Option<(i64, i64)> {
let div = power_divisor(power);
let drained = i64::from(amount) / div;
(drained != 0).then(|| {
(
drained,
((f64::from(amount) * f64::from(multiplier)) as i64) / div,
i64::from(rounded_product(amount, multiplier)) / div,
)
})
}
/// `627a8f`..`627ac9` — the product `amount · multiplier`, reduced to an integer the way the
/// reference reduces it, which is not `as i64` and not `__ftol`.
///
/// `fild` the amount, `fmul` the f32 multiplier, then **`fst dword [ebp-0x10]` rounds the product
/// to f32** (`0x627a95`) — that narrowing is real and is why this is not done in `f64`. The
/// reduction is then a hand-rolled round-half-away-from-zero: double it, bias by ∓0.5 on the
/// product's own sign (`0x627aa5`/`0x627ab5`, against `[0x8628f4] = 0.5`), `fistp` under the
/// default round-half-to-even, and `sar 1` back down (`0x627ac9`).
///
/// For the non-negative products a leech actually produces this lands on `floor(p)` — the
/// truncation 2117 recorded — so the figures are the same and only the edges differ. The edges
/// are the point: at f32 precision an `f64` product can round to the other side of an integer,
/// and the whole family exists so an addon reads the same number off our log as off the
/// reference's.
fn rounded_product(amount: u32, multiplier: f32) -> i32 {
let product = (f64::from(amount) * f64::from(multiplier)) as f32; // the `fst` narrowing
let biased = f64::from(product).mul_add(2.0, if product < 0.0 { 0.5 } else { -0.5 });
// `fistp` truncates to i32 under the default control word: round half to even.
(biased.round_ties_even() as i32) >> 1
}
/// The periodic handler's **pre-divide** — `0x627126`/`0x627138` scales the tick's amount by
/// `0x6e7130(powerType)` *before* handing it to `0x627930`, which then divides by the very same
/// table again.
///
/// It is a reference bug, and we reproduce it deliberately. The positive control is its own
/// sibling: the energize tick pre-divides identically at `0x627087` and `0x627520` contains no
/// divide at all, so the double is specific to aura 64. Reproduced because this family exists to
/// be *read* — MikScrollingBattleText parses these sentences, and a number that disagrees with the
/// reference's is the bug, whichever client is arithmetically right. It is the same posture
/// [`spell_log_miss`] already takes toward `0x5e7f31`'s chat type.
///
/// Mana is unaffected by construction (divisor 1, so the second divide is the identity), which is
/// every leech anyone has looked at; a periodic rage leech would come out at a hundredth. Whether
/// any 1.12 aura-64 row names a non-mana power is **not measured here**.
fn periodic_leech_amount(power: u32, amount: u32) -> u32 {
(i64::from(amount) / power_divisor(power)) as u32
}
/// `0x627930` — **the** power leech/drain formatter, for both of its call sites.
///
/// One function because the reference has one function. `SMSG_PERIODICAURALOG` aura 64 enters it
/// through `0x627910` with the periodic flag **set**; `SMSG_SPELLLOGEXECUTE` effect 8
/// `POWER_DRAIN` enters it with the flag **clear**; everything between the two entries is shared.
/// Ours were two arms that had drifted apart — the execute-log one forked three ways and the
/// periodic one worded `SPELLPOWERLEECH` unconditionally, which 2117 named as residue and 2157
/// closes.
///
/// The arms, in the order the bytes test them (read off `0x627930` this session):
///
/// 1. **`powerType == 4` → `0x627de0`**, the *first* test in the function (`62793c cmp edi,4`).
/// It is a **fall-through, not a diversion**: `627953 test al,al` / `627955 jne` leaves only
/// when `0x627de0` actually emitted, so a happiness drain whose pet→owner chain does not
/// resolve carries on down the generic path and words `SPELLPOWERLEECH`/`…DRAIN` with the
/// `HAPPINESS_POINTS` noun.
/// 2. **`powerType >= 5` → no line**, and the gate is `0x6278f0`'s own `cmp ecx,5; jae` answering
/// NULL for the power noun (`627964 test edi,edi` / `627966 je`) — *not* the four `0x64a4c0`
/// name compares much further down, which run after the emit. The compare is **unsigned**, so
/// a negative tag drops here too. Ours is [`combat::power_word`] answering `None`.
/// 3. the spell gates — `SpellRec+0x18 & 0x180` and an empty localized name — which are the
/// caller's [`ChatCtx::spell_name`], already run before we get here.
/// 4. the shared resolve + range gate `0x626630`, which is [`queue`]'s.
/// 5. **the LEECH/DRAIN fork** on `|multiplier| >= 2^-22` (`6279ff fcomp [0x8029d4]`; the `jp`
/// takes greater-or-equal, so equality is a leech).
/// 6. **the periodic flag picks the chat-type stub** — `0x627d60` periodic, `0x627d30` direct —
/// and only here, *after* the fork.
/// 7. **`drained == 0` drops the line** (`627ae3 test esi,esi` / `627ae5 je`), after the divide.
#[allow(clippy::too_many_arguments)] // one reference function's full argument set
fn power_drain_line(
log: &mut ChatLog,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
spell: &str,
spell_id: u32,
caster: (u64, UnitClass),
target: (u64, UnitClass),
power: u32,
amount: u32,
multiplier: f32,
periodic: bool,
) {
// Arm 1. The happiness sentence is tried first and only *consumes* the packet when it emits.
if power == POWER_HAPPINESS && happiness_drain_line(log, ctx, stores, poses, target, amount) {
return;
}
// Arm 2. `0x6278f0`'s bound, expressed where the noun is: no noun, no line.
if !combat::power_has_word(power) {
return;
}
// Arms 5 and 7's arithmetic.
let Some((drained, gained)) = leech_figures(power, amount, multiplier) else {
return;
};
let family = leech_family(multiplier);
// Arm 6. Both stubs ask the SPELL, not the family: `0x627d30` is
// `call 0x6ea280; cmp eax,2; je -> 0x626be0 (damage) else 0x627820 (buff)` and `0x627d60` is
// its periodic twin over `0x627d80`/`0x6274a0` (§2.3 of wow-re's combat-log chat law). So a
// helpful drain files under `…_BUFF` and a harmful one under `…_DAMAGE`, which is a property
// of the row in `Spell.dbc` and not something either call site may hardcode — both of ours
// did, in opposite directions.
let buff = !ctx.spell_harmful(spell_id);
// The periodic stub hands its selector `outClassA`, the **caster** (`0x627a0f`/`0x627a3a`) —
// the row [`periodic_subject`] records for this tick kind.
let kind = if periodic {
combat::periodic_kind(caster.1, buff)
} else {
combat::spell_kind(caster.1, target.1, buff)
};
let Some(kind) = kind else {
return;
};
queue(
log,
ctx,
poses,
kind,
family,
caster,
target,
Fills {
spell: spell.to_owned(),
power: Some(power),
power2: Some(power),
amount: drained,
amount2: gained,
..Default::default()
},
);
}
/// `0x627de0` — the happiness sentence, and the one arm of `0x627930` that is not about a power
/// noun at all: the subject is the pet's **owner**, the named thing is the **pet**, and the chat
/// type is the literal `0x19` misc-info rather than anything a selector picks.
///
/// **Answers whether it emitted**, because that is the byte's own contract: `0x627930` tests the
/// returned `al` and only stops when it is set, so a pet whose owner chain does not resolve is not
/// a dropped line — it is a line that comes out of the generic leech/drain path instead.
///
/// The divisor is unconditionally happiness's own 1000 (`627f45 mov ecx,4`), which is what
/// [`power_divisor`] answers for tag 4, so the figure is [`leech_figures`]'s `drained` by another
/// route.
fn happiness_drain_line(
log: &mut ChatLog,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
pet: (u64, UnitClass),
amount: u32,
) -> bool {
let Some(owner) = unit_owner(pet.0, ctx, stores) else {
return false; // the `+0x110` pet→owner chain did not resolve — fall through, not drop
};
let owner_class = ctx.classify(owner, stores);
queue_reported(
log,
ctx,
poses,
crate::ui_chat::ChatEventKind::CombatMiscInfo,
combat::SPELLHAPPINESSDRAIN,
(owner, owner_class),
pet,
Fills {
amount: i64::from(amount) / power_divisor(POWER_HAPPINESS),
..Default::default()
},
combat::Named::Unit(pet.0),
)
}
/// A unit's owner guid — `CHARMEDBY` first, then `CREATEDBY`, the same pair
/// [`combat::classify`] reads. `None` when the unit is not streamed or owns itself.
fn unit_owner(guid: u64, ctx: &ChatCtx, stores: &Query<&mut ObjectStore>) -> Option<u64> {
@@ -1258,21 +1448,48 @@ fn queue_named(
fills: Fills,
named: combat::Named,
) {
queue_reported(log, ctx, poses, kind, family, subject, object, fills, named);
}
/// [`queue_named`], answering **whether a line was actually queued**.
///
/// One caller reads the verdict and has to: `0x627de0` reports its success to `0x627930` in `al`
/// (`mov al,1` at `0x627fb0`/`0x627fe2`/`0x627ff4` against `0x628006 xor al,al`), and a false
/// there is a **fall-through** to the generic leech/drain path, not a dropped line. Both failure
/// shapes below are among the ones the reference reports: its own range gate, and an empty
/// template.
#[allow(clippy::too_many_arguments)] // as [`queue_named`], whose body this is
fn queue_reported(
log: &mut ChatLog,
ctx: &ChatCtx,
poses: &Query<&mut Transform>,
kind: crate::ui_chat::ChatEventKind,
family: Family,
subject: (u64, UnitClass),
object: (u64, UnitClass),
fills: Fills,
named: combat::Named,
) -> bool {
// **The gate is an OR over the two endpoints, not an AND** — the §5 verdict's own wording:
// `dist²(player, src) < range(srcClass)²` **OR** `dist²(player, tgt) < range(tgtClass)²`. 1571
// required both and therefore dropped lines the client shows: your own pet (range 100000)
// fighting something 80 yards away is logged by the reference and was silent here.
if !ctx.in_range(subject.0, subject.1, poses) && !ctx.in_range(object.0, object.1, poses) {
return;
}
if let Some(line) = combat::queue(kind, family, subject, object, fills, named) {
log.push_combat(line);
return false;
}
let Some(line) = combat::queue(kind, family, subject, object, fills, named) else {
return false;
};
log.push_combat(line);
true
}
#[cfg(test)]
mod tests {
use super::{leech_figures, periodic_subject, power_gain, PeriodicTick, UnitClass};
use super::{
combat, leech_family, leech_figures, periodic_leech_amount, periodic_subject, power_gain,
rounded_product, PeriodicTick, UnitClass,
};
/// **A periodic line is typed off the endpoint its own formatter reads** (decision 2127).
///
@@ -1361,4 +1578,81 @@ mod tests {
// Happiness is the ×1000 arm; a pet's 1500 raw is one displayed point.
assert_eq!(leech_figures(4, 1500, 0.0), Some((1, 0)));
}
/// The LEECH/DRAIN fork the periodic arm did not have — one formatter, and until 2157 its two
/// call sites disagreed about every choice it makes.
///
/// The epsilon is the reference's own `[0x8029d4] = 2^-22`, and the compare's `jp` takes the
/// greater-or-equal side, so a multiplier sitting exactly on it is a **leech**. That boundary
/// is the whole reason this is a named function and not an `if multiplier != 0.0`.
#[test]
fn the_leech_drain_fork_is_the_references_own_epsilon() {
const EPS: f32 = 1.0 / 4_194_304.0;
assert_eq!(leech_family(1.0).stem, "SPELLPOWERLEECH");
assert_eq!(
leech_family(EPS).stem,
"SPELLPOWERLEECH",
"equality leeches"
);
assert_eq!(leech_family(EPS / 2.0).stem, "SPELLPOWERDRAIN");
assert_eq!(leech_family(0.0).stem, "SPELLPOWERDRAIN");
// `fabs` first: the sign is not the question the reference asks.
assert_eq!(leech_family(-1.0).stem, "SPELLPOWERLEECH");
}
/// `0x6278f0`'s table is five entries, not four — **happiness has a noun**.
///
/// `cmp ecx,5; jae` is the bound, and it is unsigned. We stopped the table at energy and
/// called happiness "no GlobalString", which silently dropped every line the reference words
/// with `HAPPINESS_POINTS = "Happiness"` (shipped enUS `GlobalStrings.lua:2117`): the generic
/// leech/drain fall-through out of `0x627de0`, and a happiness `POWERGAIN` tick. What
/// happiness actually lacks is a `COMBAT_TEXT_UPDATE` tag, which is a different table.
#[test]
fn every_wire_power_tag_has_a_noun_and_nothing_past_the_table_does() {
for power in 0..=4 {
assert!(combat::power_has_word(power), "power {power} has a noun");
}
for power in [5, 6, 99, u32::MAX] {
assert!(!combat::power_has_word(power), "power {power} has none");
}
}
/// The gained figure is reduced to an integer the way `0x627930` reduces it — through an
/// **f32** product, not an `f64` one.
///
/// The first two cases are the arithmetic anyone would write anyway. The third is the
/// mechanism: `fst dword [ebp-0x10]` at `0x627a95` narrows the product to f32 before the
/// rounding, and 2^24+1 is the smallest amount where that narrowing is visible — the f64
/// product is exact and the f32 one rounds to even, one below. No leech ships an amount that
/// large; the case is here because it is the only thing that can tell the two implementations
/// apart, and an `as i64` over f64 silently passes everything else.
#[test]
fn the_gained_figure_goes_through_the_references_f32_product() {
assert_eq!(rounded_product(25, 0.9), 22, "floor, not round");
assert_eq!(rounded_product(120, 0.5), 60);
assert_eq!(
rounded_product(16_777_217, 1.0),
16_777_216,
"the `fst` narrowing"
);
}
/// **The periodic leg divides twice, and we reproduce it.** `0x627126`/`0x627138` scales the
/// tick's amount by `0x6e7130(powerType)` before `0x627930` divides by the same table again.
///
/// Mana cannot show it — divisor 1, so the second divide is the identity, and that is every
/// leech anyone has looked at. Rage can: a wire 100 is 10 through the pre-divide and 1 by the
/// time the sentence is written.
#[test]
fn a_periodic_leech_is_pre_divided_before_the_formatter_divides_again() {
assert_eq!(periodic_leech_amount(0, 400), 400, "mana: the identity");
assert_eq!(periodic_leech_amount(1, 100), 10, "rage: the first divide");
// End to end on the rage case: 100 on the wire words as 1, not 10 and not 100.
assert_eq!(
leech_figures(1, periodic_leech_amount(1, 100), 1.0),
Some((1, 1))
);
// The direct packet takes the same wire number through one divide only.
assert_eq!(leech_figures(1, 100, 1.0), Some((10, 10)));
}
}
+13 -2
View File
@@ -928,6 +928,17 @@ impl WorldMouse {
pub(super) fn latch_world_mouse(
buttons: Res<ButtonInput<MouseButton>>,
pointer_over_ui: Res<PointerOverUi>,
// **A V-plate is UI the camera looks straight through** (decision 2148). Plates became real
// mouse-enabled widgets, so the pointer inside one makes `PointerOverUi` true — which is right
// for the world PICK (the plate publishes the mouseover itself, and its click selects) and
// wrong for the camera: a drag that begins over a plate has to turn the view, or nameplates
// become dead patches you cannot swing the camera from. The reference says the same thing from
// the other end — entering freelook DISABLES plate mouse input (`0x60f830`, from `0x483e80`),
// a toggle that would have nothing to do if a press on a plate could not reach freelook.
//
// The click does NOT leak through with it: `PlayerUiClickConsumed` still suppresses the
// `WorldClick`/`WorldRightClick` below, so a plate click stays the plate's own.
plate_hover: Res<crate::vplates::PlateHover>,
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
window: Single<&Window, With<PrimaryWindow>>,
@@ -935,8 +946,8 @@ pub(super) fn latch_world_mouse(
let Ok(camera) = cameras.single() else {
return;
};
let world_press =
rig.look.is_some() || (cursor_in_viewport(&window, camera) && !pointer_over_ui.0);
let over_ui = pointer_over_ui.0 && plate_hover.0.is_none();
let world_press = rig.look.is_some() || (cursor_in_viewport(&window, camera) && !over_ui);
rig.world_mouse.update(&buttons, world_press);
}
+66
View File
@@ -46,6 +46,72 @@ pub(super) fn world_right_click_payload(
script.clear_cursor_payload();
}
/// **A plate click is a click on its unit** — the plate's own click slot (`0x7cb910`) ends in the
/// same `SetSelection` a click on the body does (decision 2148).
///
/// It **carries its own unit** rather than reading the press pick, and that is the whole design:
/// pfUI's click-through calls `plate:Click("LeftButton")` with the cursor wherever the player left
/// it (`nameplates.lua:1274`), so a plate click that depended on the hover would work under the
/// mouse and silently do nothing for the addon it exists to serve. The first cut of this system
/// replayed a [`WorldClick`] and was guarded on the press pick naming the same unit; the live probe
/// caught it — the scripted click selected nothing.
///
/// The RIGHT button is the other way round, deliberately. [`act_on_right_click`] acts on the LIVE
/// hover across many legs (interact, attack, loot, GameObject), so feeding it a unit the pointer is
/// not on could attack the wrong thing; it is replayed only when the hover already names the
/// plate's unit — the physical case. A scripted right-click on a plate does nothing yet, which is
/// a stated gap rather than a guess about which leg it should take.
#[allow(clippy::too_many_arguments)]
pub(super) fn select_on_plate_click(
mut plate: ResMut<crate::vplates::PlateClicks>,
press: Res<PressPick>,
hovered: Res<Hovered>,
ground: Res<crate::ui_action::SpellTargeting>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
self_q: Query<(&Guid, Has<Engaged>), With<SelfPlayer>>,
mut greeting: MessageWriter<crate::sound::NpcGreetingRequest>,
mut right_clicks: MessageWriter<WorldRightClick>,
units: Query<(&Guid, Option<&ObjectStore>)>,
) {
let (left, right) = (
std::mem::take(&mut plate.left),
std::mem::take(&mut plate.right),
);
// The ground-targeting cursor owns the click, exactly as it does for a world one (0792).
if ground.active() {
return;
}
let (self_guid, engaged) = self_q
.single()
.map(|(g, e)| (Some(g.0), e))
.unwrap_or((None, false));
for entity in left {
let Ok((guid, store)) = units.get(entity) else {
continue; // the unit left between the click and this frame
};
// The NPC greets us on the SELECT gesture, plate or body — the byte-verified trigger fires
// before SetTarget either way (`0x60c270`).
greeting.write(crate::sound::NpcGreetingRequest { npc: entity });
// The attack classification is the press pick's, and only when the press was actually on
// this unit: a scripted click has no cursor behind it, so it is not an attack-cursor click.
let attack = press.attack && press.hovered.target == Some(entity);
scan::commit(
&mut selection,
&mut seam,
entity,
guid.0,
store,
engaged,
self_guid,
attack,
);
}
if right.into_iter().any(|e| hovered.target == Some(e)) {
right_clicks.write(WorldRightClick);
}
}
/// On a [`WorldClick`], select the unit the **press** was over ([`PressPick`]) and inform the
/// server; a click on empty ground / a non-unit clears the target — except a click on NOTHING (sky
/// — no occlusion-ray hit) while a payload is held: the reference's nothing-leg deselect is
+20 -14
View File
@@ -134,8 +134,9 @@ pub(super) fn update_hover(
// The frame's world-occlusion distance: the final pick is discarded iff the world hit is
// strictly nearer (behind terrain/WMO/doodad geometry — the post-hoc compare below).
occlusion: Res<PickOcclusion>,
// The V-plates' screen rects (last frame's layout — the plate drive runs after this chain).
plate_rects: Res<crate::vplates::PlateRects>,
// The unit whose plate the pointer is inside (last frame's layout — the plate drive runs
// after this chain), published by the plate widgets themselves.
plate_hover: Res<crate::vplates::PlateHover>,
mut hovered: ResMut<Hovered>,
mesh_assets: Res<Assets<Mesh>>,
pose: PickPose,
@@ -199,6 +200,23 @@ pub(super) fn update_hover(
hovered.corpse_guid = None;
hovered.distance = f32::MAX;
hovered.refused = false;
// **The plate publishes the mouseover, and it does so THROUGH the UI gate below, not around
// it.** A plate is mouse-enabled UI (2148): with the pointer inside one, the UI pointer pass
// owns the cursor, `PointerOverUi` is true, and the world pick correctly stands down — so this
// has to be read before that early return or hovering a plate would clear the mouseover it is
// supposed to set. That is the reference's own arrangement: the plate's OnEnter (`0x7cb850`)
// publishes `[0xb4e2c8]` directly, with none of the world hover's grading, and the frame walk
// stops there — no world pick behind it. Freelook still wins, because there the plates have
// handed the mouse back (`0x60f830`).
if !rig.is_looking() {
if let Some(entity) = plate_hover.0 {
hovered.target = Some(entity);
hovered.guid = units.get(entity).ok().map(|(g, _)| g.0);
hovered.distance = 0.0; // topmost UI — it beats any world GameObject at a tie
*last_pick = Some(entity);
return;
}
}
if rig.is_looking() || pointer_over_ui.0 {
*last_pick = None;
return;
@@ -209,18 +227,6 @@ pub(super) fn update_hover(
let Some(cursor) = window.cursor_position() else {
return;
};
// The plate is mouse-enabled UI on the reference: the cursor inside a plate's rect makes
// that plate's unit the mouseover (OnEnter `0x7cb850` → `[0xb4e2c8]`) and the frame walk
// stops there — no world pick behind it. So plate hover brightens the model, lights the
// plate bar, and click-selects, exactly like hovering the body. Reverse order = the later-drawn
// (visually topmost) plate wins an overlap.
if let Some(&(_, entity)) = plate_rects.0.iter().rev().find(|(r, _)| r.contains(cursor)) {
hovered.target = Some(entity);
hovered.guid = units.get(entity).ok().map(|(g, _)| g.0);
hovered.distance = 0.0; // a plate is topmost UI — it beats any world GameObject at a tie
*last_pick = Some(entity);
return;
}
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
return;
};
+7 -3
View File
@@ -36,8 +36,10 @@
//! ([`highlight`], the byte-verified additive term) and the V-plate's reaction-tinted **glow**
//! (`crate::vplates`, drawn later off [`Hovered`] + [`Selection`]). The plate rect is itself part
//! of the pick: hovering a plate makes its unit the mouseover *before* any world ray-test
//! ([`hover::update_hover`] consults `crate::vplates::PlateRects` first — the reference's
//! UI-blocks-world frame walk), so plates brighten the model and take clicks like the body does.
//! ([`hover::update_hover`] reads `crate::vplates::PlateHover` first — the plate is real
//! mouse-enabled UI since 2148/2159, so it takes the pointer and publishes the unit itself, the
//! reference's own OnEnter arrangement), so plates brighten the model and take clicks like the
//! body does ([`click::select_on_plate_click`]).
//! The real pick's **world-occlusion clamp** is modelled ([`PickOcclusion`]): the reference's scene
//! trace runs `CWorld::Intersect` first and the object pick only wins nearer than the world hit —
//! a unit or GameObject behind a wall is not hoverable. Not yet modelled: the header-sphere
@@ -413,7 +415,9 @@ impl Plugin for TargetPlugin {
// range state — the ref's one CheckGroundPointInRange caller feeds both).
reticle::update_reticle,
click::world_right_click_payload,
click::select_on_click,
// A plate click replayed as the click it is (2148), then the select that
// reads it. One element, chained: the outer chain is at Bevy's 20-tuple limit.
(click::select_on_plate_click, click::select_on_click).chain(),
// AFTER the gated select (which holds while the mode is active): the commit
// may clear the mode, and the selection gate must have read it first. The two
// world legs are siblings, not a fallback chain — the pending spell's word
+71 -27
View File
@@ -1022,18 +1022,43 @@ pub(crate) fn school_word(script: &benilla_ui::script::UiScript, school: u8) ->
global_string(script, &format!("SPELL_SCHOOL{school}_CAP"))
}
/// The GlobalString key `0x6278f0` resolves for a power tag, or `None` where it answers NULL.
///
/// The function is five lines and both of them matter: `cmp ecx,5; jae` — **unsigned**, so a tag
/// of 5 or more (and a negative one, which reads as huge) returns NULL — else
/// `[4*ecx + 0x85645c]` into `0x703bf0`. The table's five entries are the five `Powers` the wire
/// carries, **happiness included**: it is `HAPPINESS_POINTS = "Happiness"` (shipped enUS
/// `GlobalStrings.lua:2117`), not a hole. This file used to stop the table at energy and call
/// happiness "no GlobalString", which dropped every line the reference words with it — the
/// generic leech/drain fall-through out of `0x627de0`, and a happiness `POWERGAIN` tick.
///
/// What happiness really lacks is a **`COMBAT_TEXT_UPDATE`** tag: `0x627520`/`0x627930`'s four
/// `0x64a4c0` compares match only the other four nouns, so it produces the chat line and no
/// floating text (§4.6). That is a different table, further down, after the emit.
fn power_key(power: u32) -> Option<&'static str> {
match power {
0 => Some("MANA_POINTS"),
1 => Some("RAGE_POINTS"),
2 => Some("FOCUS_POINTS"),
3 => Some("ENERGY_POINTS"),
4 => Some("HAPPINESS_POINTS"),
_ => None,
}
}
/// Whether `0x6278f0` answers a noun at all — the `cmp ecx,5; jae` bound, without a VM in hand.
///
/// The formatters gate on the returned pointer (`627964 test edi,edi; 627966 je`) long before they
/// reach a template, so a caller that has no script yet still has to be able to ask.
pub(crate) fn power_has_word(power: u32) -> bool {
power_key(power).is_some()
}
/// The power word a `POWERGAIN`/`SPELLPOWERLEECH`/`SPELLPOWERDRAIN` template takes, by the vmangos
/// `Powers` index the wire carries (0 mana · 1 rage · 2 focus · 3 energy). Happiness (4) has no
/// GlobalString and no combat-log line, so it answers `None` and the line is dropped.
/// `Powers` index the wire carries — `0x6278f0`'s table, resolved through the same GlobalString
/// mechanism the reference uses ([`power_key`] carries the law).
pub(crate) fn power_word(script: &benilla_ui::script::UiScript, power: u32) -> Option<String> {
let key = match power {
0 => "MANA_POINTS",
1 => "RAGE_POINTS",
2 => "FOCUS_POINTS",
3 => "ENERGY_POINTS",
_ => return None,
};
global_string(script, key)
global_string(script, power_key(power)?)
}
/// Resolve one endpoint's display name — the reference's `GetObjectName` (`0x6264e0`), which is the
@@ -1771,50 +1796,69 @@ pub(crate) fn miss_family(miss_info: u8) -> Family {
/// damage == 0 && HitInfo & 0x20 → VSABSORB (ABSORB)
/// damage == 0 && HitInfo & 0x40 → VSRESIST (RESIST)
/// VictimState == 1 && damage > 0 → COMBATHIT[CRIT][SCHOOL]
/// otherwise → the VictimState word
/// otherwise → the VictimState word, or NO LINE
/// ```
///
/// **The last arm can decline.** `0x62a710` is gated twice before it words anything: the 10-entry
/// flag table `0x8628f8` = `[0,0,1,1,0,1,1,1,1,0]` indexed by VictimState (`0x62a720`), then
/// `add eax,-2; cmp eax,6; ja` into the jump table `0x62a8ec` — so VictimState **0, 1, 4 and 9
/// emit no line at all** (§4.1 row 6). This used to answer `MISSED` for them and call that "the
/// reference's own fall-through", which it is not: the reference stays silent, and a `MISSED`
/// there is a sentence the real client never prints.
///
/// The bit values are vmangos's `HitInfo` under the `> 1.9.4` conditional that is compile-time true
/// for 5875 (`Objects/UnitDefines.h:250-268`) and its `VictimState` (`:237-248`) — the same pair
/// [`crate::sound::combat`] and [`crate::combat_text`] already read, here named once instead of a
/// fourth set of bare literals.
pub(crate) fn melee_family(hit_info: u32, victim_state: u32, damage: u32, school: u8) -> Family {
pub(crate) fn melee_family(
hit_info: u32,
victim_state: u32,
damage: u32,
school: u8,
) -> Option<Family> {
const MISS: u32 = 0x10;
const ABSORB: u32 = 0x20;
const RESIST: u32 = 0x40;
const CRIT: u32 = 0x80;
if hit_info & MISS != 0 {
return MISSED;
return Some(MISSED);
}
if victim_state == 5 {
return VSBLOCK;
return Some(VSBLOCK);
}
if damage == 0 {
if hit_info & ABSORB != 0 {
return VSABSORB;
return Some(VSABSORB);
}
if hit_info & RESIST != 0 {
return VSRESIST;
return Some(VSRESIST);
}
}
if victim_state == 1 && damage > 0 {
return match (hit_info & CRIT != 0, school != 0) {
return Some(match (hit_info & CRIT != 0, school != 0) {
(false, false) => COMBATHIT,
(true, false) => COMBATHITCRIT,
(false, true) => COMBATHITSCHOOL,
(true, true) => COMBATHITCRITSCHOOL,
};
});
}
// `0x62a710`'s own two gates, and they are the whole arm. The flag table's `1`s are exactly
// these five; VictimState 0 (UNAFFECTED), 1 with no damage, 4 (INTERRUPT) and 9 are `0`s and
// the formatter returns having emitted nothing.
//
// The table's index 5 is a `1`, and it is unreachable: a block is taken above, which is why
// the two readings have to be kept apart rather than collapsed into one list of five.
// A VictimState of 10 or more is not bounds-checked before the table read — the load runs on
// a wire `u32` and index 10 lands in the adjacent pointer table `0x862920`, whose entries are
// all non-zero, so gate 1 *passes* and the second gate's `cmp eax,6; ja` is what rejects it.
// No line, nothing fired — which is the answer `None` already gives.
match victim_state {
2 => VSDODGE,
3 => VSPARRY,
6 => VSEVADE,
7 => VSIMMUNE,
8 => VSDEFLECT,
// VictimState 0 (UNAFFECTED, "seen in relation with HITINFO_MISS") and 4 (INTERRUPT) reach
// here only on a shape vmangos does not send; MISSED is the reference's own fall-through
// and is the least wrong thing to say about a swing that did nothing.
_ => MISSED,
2 => Some(VSDODGE),
3 => Some(VSPARRY),
6 => Some(VSEVADE),
7 => Some(VSIMMUNE),
8 => Some(VSDEFLECT),
_ => None,
}
}
+45 -17
View File
@@ -161,14 +161,18 @@ fn the_school_and_power_words_resolve() {
"SPELL_SCHOOL{school}_CAP missing"
);
}
for power in 0..=3u32 {
// `0x6278f0`'s table is FIVE entries — `cmp ecx,5; jae` — and happiness is the fifth. This
// used to stop at 3 and assert that happiness had no word "deliberately"; the shipped file
// says otherwise, and reading it here is what settles it.
for power in 0..=4u32 {
assert!(
power_word(&script, power).is_some(),
"power {power} missing"
);
}
// Happiness has no combat-log word, deliberately.
assert!(power_word(&script, 4).is_none());
assert_eq!(power_word(&script, 4).as_deref(), Some("Happiness"));
// Past the table's five entries the reference returns NULL and the line is dropped.
assert!(power_word(&script, 5).is_none());
}
/// The sentences a player actually reads, end to end, on the real strings — the residue
@@ -275,26 +279,50 @@ fn the_fill_is_vsnprintf_and_refuses_a_mismatch() {
/// `MISSED` (the MISS bit is not set for an absorb).
#[test]
fn the_melee_dispatcher_follows_the_reference_order() {
let f = |h, v, d, s| melee_family(h, v, d, s).expect("a family").stem;
// A plain landed swing, and its crit and school variants.
assert_eq!(melee_family(0, 1, 120, 0).stem, "COMBATHIT");
assert_eq!(melee_family(0x80, 1, 120, 0).stem, "COMBATHITCRIT");
assert_eq!(melee_family(0, 1, 120, 2).stem, "COMBATHITSCHOOL");
assert_eq!(melee_family(0x80, 1, 120, 2).stem, "COMBATHITCRITSCHOOL");
assert_eq!(f(0, 1, 120, 0), "COMBATHIT");
assert_eq!(f(0x80, 1, 120, 0), "COMBATHITCRIT");
assert_eq!(f(0, 1, 120, 2), "COMBATHITSCHOOL");
assert_eq!(f(0x80, 1, 120, 2), "COMBATHITCRITSCHOOL");
// MISS wins over everything, including a VictimState that would say otherwise.
assert_eq!(melee_family(0x10, 2, 0, 0).stem, "MISSED");
assert_eq!(f(0x10, 2, 0, 0), "MISSED");
// BLOCKS wins over the damage test — a partial block still lands damage.
assert_eq!(melee_family(0, 5, 90, 0).stem, "VSBLOCK");
assert_eq!(f(0, 5, 90, 0), "VSBLOCK");
// Zero damage + the absorb/resist bits.
assert_eq!(melee_family(0x20, 1, 0, 0).stem, "VSABSORB");
assert_eq!(melee_family(0x40, 1, 0, 0).stem, "VSRESIST");
assert_eq!(f(0x20, 1, 0, 0), "VSABSORB");
assert_eq!(f(0x40, 1, 0, 0), "VSRESIST");
// ...but the same bits with damage through are a landed hit, not a full absorb.
assert_eq!(melee_family(0x20, 1, 90, 0).stem, "COMBATHIT");
assert_eq!(f(0x20, 1, 90, 0), "COMBATHIT");
// The VictimState words.
assert_eq!(melee_family(0, 2, 0, 0).stem, "VSDODGE");
assert_eq!(melee_family(0, 3, 0, 0).stem, "VSPARRY");
assert_eq!(melee_family(0, 6, 0, 0).stem, "VSEVADE");
assert_eq!(melee_family(0, 7, 0, 0).stem, "VSIMMUNE");
assert_eq!(melee_family(0, 8, 0, 0).stem, "VSDEFLECT");
assert_eq!(f(0, 2, 0, 0), "VSDODGE");
assert_eq!(f(0, 3, 0, 0), "VSPARRY");
assert_eq!(f(0, 6, 0, 0), "VSEVADE");
assert_eq!(f(0, 7, 0, 0), "VSIMMUNE");
assert_eq!(f(0, 8, 0, 0), "VSDEFLECT");
}
/// `0x62a710` declines: the VictimStates whose flag-table byte is `0` produce **no line at all**.
///
/// The table `0x8628f8` is `[0,0,1,1,0,1,1,1,1,0]`, so 0, 1, 4 and 9 are silent — and 1 is the
/// interesting one, because a VictimState of 1 with damage through is the commonest line in the
/// whole log. It is arm 5 that words that, on `damage != 0`; strip the damage and the same state
/// falls to this arm and says nothing. Answering `MISSED` here (which is what we did) invents a
/// sentence the reference never prints.
#[test]
fn the_silent_victim_states_emit_no_melee_line() {
for state in [0, 1, 4, 9] {
assert!(
melee_family(0, state, 0, 0).is_none(),
"VictimState {state} must emit no line"
);
}
// The same states still word normally when an earlier arm claims them: the MISS bit, and
// state 1 with damage through.
assert!(melee_family(0x10, 0, 0, 0).is_some(), "the MISS bit wins");
assert!(melee_family(0, 1, 120, 0).is_some(), "a landed hit wins");
// Index 5 of the table is a `1`, and it is unreachable — a block is claimed by arm 2.
assert_eq!(melee_family(0, 5, 0, 0).map(|f| f.stem), Some("VSBLOCK"));
}
/// The melee msgType matrix, against `0x62a0d0`/`0x62a2e0` as decompiled and against wow-re's
+220 -35
View File
@@ -134,9 +134,12 @@ impl Addon {
/// source knows what a resolved path is allowed to reach.
///
/// **The sandbox is the AddOns folder, not one addon's subfolder.** `..` that walks above the
/// root survives `join_ref` as a leading `..`, and [`read_under`] refuses it — so an addon can
/// reach a sibling library addon (which is how `Bagnon` reaches `BagBrother`, and what 1184's
/// per-addon guard wrongly blocked) but cannot reach the machine.
/// root survives `join_ref` as a leading `..`, and [`read_under`] refuses it on the filesystem
/// leg — so an addon can reach a sibling library addon (which is how `Bagnon` reaches
/// `BagBrother`, and what 1184's per-addon guard wrongly blocked) but cannot reach the machine.
/// A `Dir` miss then falls through to the player's patch chain under the install's own name
/// for it ([`read_addon_file`], decision 2155), which is the single namespace the reference
/// opener has — and the reason `Auctioneer`'s reach into `Blizzard_AuctionUI` resolves at all.
///
/// The **builtin alone** keeps a basename fallback: its tree is flat, and a transcription that
/// writes a Blizzard-style directory path should still find the file. A `Dir` source must not
@@ -154,38 +157,53 @@ impl Addon {
.or_else(|| content::read(&base))
.map(String::into_bytes)
}
Source::Dir(root) => read_under(root, req),
Source::Dir(root) => read_addon_file(root, req),
Source::Chain => super::reference_ui::read(req),
}
}
/// This addon's own folder in its source's path space — the `base` its manifest entries are
/// relative to. `""` for the builtin's flat tree, the addon's folder name for a `Dir`, and
/// `""` again for the chain, whose manifest entries are already full internal paths.
fn prefix(&self) -> &str {
/// This addon's own folder in the install's path space — the `base` its manifest entries are
/// relative to. `""` for the builtin's flat tree, `""` for the chain (whose manifest entries
/// are already full internal paths), and `Interface/AddOns/<Folder>` for a `Dir`.
///
/// **`Interface/AddOns/`, not the bare folder name** (decision 2155). It used to be the folder
/// alone, which made the addon's path space the AddOns root rather than the install — a second
/// namespace the reference does not have. The visible cost was `<Script file=>`'s chunk NAME:
/// the loader names a file chunk after the path it resolved, so an XML-loaded Lua file came out
/// `@AtlasLoot\Core\AtlasLoot.lua` while the `.toc`-listed file beside it was
/// `@Interface\AddOns\AtlasLoot\…`. Every Ace2/FuBar-era library finds its own addon by
/// splitting a `debugstack` frame on `\AddOns\` (`AceDB-2.0.lua:742`,
/// `FuBarPlugin-2.0.lua:602`), so for those files the split found nothing and the library
/// silently keyed itself on a traceback — or, in `SSHonor_Fu`'s case, called
/// `IsAddOnLoadOnDemand(nil)`.
fn prefix(&self) -> String {
match &self.source {
Source::Builtin | Source::Chain => "",
Source::Dir(_) => &self.name,
Source::Builtin | Source::Chain => String::new(),
Source::Dir(_) => format!("{ADDONS_PREFIX}{}", self.name),
}
}
/// The name Lua gives a chunk from this source — **not cosmetic**, because addons parse it.
///
/// An addon's file is named the way the client names it, `Interface\AddOns\<Folder>\<File>`
/// ([`benilla_ui::script::addon_chunk_name`], whose header carries the FuBar `debugstack`
/// story). A **chain** file is named after its own chain path, `@Interface\FrameXML\…`, which
/// is both what the real client names it and what keeps FrameXML *out* of the `\AddOns\`
/// pattern those addons match against — FrameXML is not an addon.
/// `path` is the entry already resolved into the install's path space, which is the only thing
/// the client names a file chunk after: `"@%s"` (`0x8716e0`) over the resolved path, built by
/// `0x704bc0` for every `.lua` the loader touches (wow-re
/// `ui/scratch/include-lua-dispatch.md` §7). So an addon's file is `Interface\AddOns\<Folder>
/// \<File>` and a chain file is `Interface\FrameXML\…` — the second being both what the real
/// client names it and what keeps FrameXML *out* of the `\AddOns\` pattern the Ace2 family
/// matches against, because FrameXML is not an addon.
///
/// The loader gives an inline `<Script>` and a `<Script file=>` exactly this shape already
/// (`loader::Loader::run`), so a chain document's Lua is named identically whether it arrives
/// as a manifest entry or through its own `<Script file=>`.
fn chunk_name(&self, file: &str) -> String {
/// **One rule for both doors, which is the point** (decision 2155). `Loader::run` names a
/// `<Script file=>` chunk by exactly this rule over exactly this space, so a manifest entry and
/// an XML-referenced file now agree — they did not while a `Dir` addon's space was rooted at
/// the AddOns folder, and [`Addon::prefix`] carries what that cost.
///
/// The builtin keeps [`benilla_ui::script::addon_chunk_name`]: its tree is flat and internal,
/// so there is no resolved install path to name it after.
fn chunk_name(&self, file: &str, path: &str) -> String {
match &self.source {
Source::Builtin | Source::Dir(_) => {
benilla_ui::script::addon_chunk_name(&self.name, file)
}
Source::Chain => format!("@{}", file.replace('/', "\\")),
Source::Builtin => benilla_ui::script::addon_chunk_name(&self.name, file),
Source::Dir(_) | Source::Chain => format!("@{}", path.replace('/', "\\")),
}
}
@@ -216,7 +234,7 @@ impl Addon {
for file in files {
// A manifest entry is relative to the addon's own folder; `read` and the loader both
// work in the source's path space, so resolve once here and use it for both.
let path = benilla_ui::loader::join_ref(self.prefix(), file);
let path = benilla_ui::loader::join_ref(&self.prefix(), file);
let Some(bytes) = self.read(&path) else {
let e = format!("{}/{file}: not found", self.name);
// Severity follows whose manifest lied. For the builtin that is us — a client
@@ -244,7 +262,7 @@ impl Addon {
// The same execution `<Script file=>` gets (`loader::mod.rs`): one chunk, run in
// the one global state, in manifest order. The reference does exactly this —
// `AddOn_Load 0x51f240` hands each listed file to `0x6edb90` regardless of kind.
match script.run_chunk_named(&bytes, &self.chunk_name(file)) {
match script.run_chunk_named(&bytes, &self.chunk_name(file, &path)) {
Ok(()) => info!("ui_script: {}/{file} ran", self.name),
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
@@ -277,6 +295,15 @@ impl Addon {
// loader takes the path and does that itself, so it can also name the file a raise
// came from (1217).
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
// `FrameXML_Debug`'s trace lines, if the player turned it on (2160). Log-only, and
// never retained: the reference files them into the same per-document record as the
// errors, but at severity 0 and *where that record surfaces is an open question in
// wow-re* (`framexml-debug-trace-flag.md`, and `xml-template-name-lookup.md` §9 for
// severity 1). So they go where a trace can go without claiming a surface we have not
// derived — and the switch that produces them is off unless an addon asks.
for t in &report.traces {
info!("ui_script({}/{file}): {t}", self.name);
}
for w in &report.warnings {
warn!("ui_script({}/{file}): {w}", self.name);
// …and retained where a player can read it (2135). The `warn!` above is the
@@ -285,6 +312,22 @@ impl Addon {
// than inside the loader — only this caller knows which file the warning is from.
script.report_warning(&format!("{}/{file}: {w}", self.name));
}
// **A file the document named and the provider does not have — the same rule as a
// `.toc` line naming no file** (decision 2155, the rule 2107 unified for the walk and
// the demand load, at the two doors it did not reach). Severity by whose manifest
// lied, retained where a player can read it, and never a script error: the reference
// logs `Couldn't open %s` / `Error loading %s` and carries on with nothing raised.
// Still a `failures` entry, so our own boot tests fail on a `benilla.toc` document
// that names a file the chain does not hold.
for m in &report.missing_files {
let e = format!("{}/{file}: {m}", self.name);
match self.source {
Source::Builtin | Source::Chain => error!("ui_script: {e}"),
Source::Dir(_) => warn!("ui_script: {e}"),
}
script.report_load_failure(&e);
failures.push(e);
}
for e in &report.errors {
error!("ui_script({}/{file}): {e}", self.name);
// Same contract as the Lua arm above: a `<Script file=>` chunk that raised or an
@@ -337,6 +380,55 @@ fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
std::fs::read(root.join(rel)).ok()
}
/// **THE file namespace — install-relative, one space for every source** (decision 2155).
///
/// `req` is a path the loader has already resolved and collapsed
/// ([`benilla_ui::loader::join_ref`]) in the client's own space: `Interface/AddOns/<Folder>/…` for
/// an addon's file, `Interface/FrameXML/…` for one that walked out of the AddOns tree. The
/// reference has exactly one such space — `0x647e60` answers a name from a hash index of the
/// install tree keyed by **install-root-relative path**, and then, attempt #4, from the MPQ chain
/// (wow-re `ui/scratch/include-lua-dispatch.md` §4.1, VERIFIED; loose *before* archive, which is
/// the order below). Two spaces is what this client had, and it cost three separate things:
///
/// - `..\Blizzard_AuctionUI\Blizzard_AuctionUITemplates.xml`, a `.toc` line in `Auctioneer` and
/// in `BeanCounter`, collapses to `Interface/AddOns/Blizzard_AuctionUI/…` — a folder that is a
/// `.pub` decoy on disk with the real files inside `patch.MPQ`. Only the chain leg has it.
/// - `..\..\FrameXML\Fonts.xml`, a `.toc` line in `JIM_toolbox` and in `SpecialTalentUI`,
/// collapses out of the AddOns tree entirely, to `Interface/FrameXML/Fonts.xml`. Same leg.
/// (wow-re records both by name as **RESOLVING** on the real client —
/// `ui/scratch/xml-toc-path-resolution.md` §5 cases 1 and 2.)
/// - and the one that was invisible: with the addon space rooted at the AddOns folder, a
/// `<Script file=>` chunk was NAMED after that space — `@AtlasLoot\Core\AtlasLoot.lua` — while
/// a `.toc`-listed one was named `@Interface\AddOns\AtlasLoot\…`. See [`Addon::chunk_name`].
///
/// **The sandbox is stricter than it was, not looser.** Only a path under `Interface/AddOns/`
/// touches the filesystem at all, and [`read_under`] still refuses to escape the root once the
/// prefix is stripped; everything else can only reach the read-only archive chain, whose names
/// cannot leave `Interface\`'s own tree. An addon reaches a sibling and the client's own interface
/// files, and never the machine.
pub(crate) fn read_addon_file(root: &Path, req: &str) -> Option<Vec<u8>> {
under_addons(req)
.and_then(|rel| read_under(root, rel))
.or_else(|| super::reference_ui::read(&req.replace('/', "\\")))
}
/// `req` with the `Interface/AddOns/` prefix stripped, or `None` if it does not carry one.
///
/// Case-insensitively, because the reference is a Windows client: a `.toc` writing
/// `..\..\Interface\Addons\Foo\bar.lua` names the same file there, and on a case-sensitive
/// filesystem an exact compare would silently send it to the chain-only leg.
fn under_addons(req: &str) -> Option<&str> {
let rest = req.get(ADDONS_PREFIX.len()..)?;
req.get(..ADDONS_PREFIX.len())
.filter(|p| p.eq_ignore_ascii_case(ADDONS_PREFIX))
.map(|_| rest)
}
/// Where a `Source::Dir` addon's files live in the install's path space — the base every one of
/// its manifest entries resolves against, with its folder name appended
/// ([`Addon::prefix`]). `/`-separated, because that is [`benilla_ui::loader::join_ref`]'s space.
const ADDONS_PREFIX: &str = "Interface/AddOns/";
/// **The** addon folder — `<benilla-config>/AddOns/` — or `None` when there is none to read.
///
/// **One root, never two** (decision 1185, the director's call). 1184 searched the WoW install's
@@ -958,7 +1050,7 @@ impl Walk {
// own files (whose functions a binding body calls), before its saved variables. Read
// through the addon's own reader, so the AddOns-root sandbox (1186) covers it like every
// other file it loads; absent is the normal case and silent.
let bindings_xml = benilla_ui::loader::join_ref(addon.prefix(), "Bindings.xml");
let bindings_xml = benilla_ui::loader::join_ref(&addon.prefix(), "Bindings.xml");
if let Some(bytes) = addon.read(&bindings_xml) {
match benilla_ui::bindings_xml::parse(&benilla_ui::source::decode(&bytes)) {
Ok(bindings) => script.register_addon_bindings(&addon.name, &bindings),
@@ -1062,7 +1154,8 @@ mod tests {
}
/// **The sandbox is the AddOns folder** (decision 1186): a sibling addon is reachable, the
/// machine is not.
/// machine is not — now expressed as *only a path under `Interface/AddOns/` touches the
/// filesystem at all* (2155), which is the same rule one level up and strictly tighter.
///
/// 1184 drew the line at each addon's own folder, which reads as the safer choice and breaks
/// the single most common structure in the ecosystem — a shared library addon exists precisely
@@ -1089,32 +1182,124 @@ mod tests {
source: Source::Dir(root),
};
// **Every path here is install-relative now** (2155) — the base is
// `Interface/AddOns/<Folder>`, which is what `prefix()` hands the loader.
assert_eq!(addon.prefix(), "Interface/AddOns/Probe");
let src = "Interface/AddOns/Probe/src";
// Its own file, and a sibling library addon reached the way a real addon reaches one.
assert_eq!(
addon.read(&join_ref("Probe/src", "own.txt")).as_deref(),
addon.read(&join_ref(src, "own.txt")).as_deref(),
Some(&b"yes"[..])
);
assert_eq!(
addon
.read(&join_ref("Probe/src", "..\\..\\ProbeLib\\core\\lib.xml"))
.read(&join_ref(src, "..\\..\\ProbeLib\\core\\lib.xml"))
.as_deref(),
Some(&b"sibling"[..]),
"a shared library addon must be reachable — this is what 1184 wrongly blocked"
);
// Above the AddOns root is refused: the escape survives `join_ref` as a leading `..` and
// `read_under` will not join it.
// Above the AddOns root touches no file at all: the collapsed path leaves
// `Interface/AddOns/`, so it never reaches `read_under` and can only ask the archive chain
// — which is stricter than the leading-`..` refusal this replaced, because a `..` that
// lands back INSIDE the install (`Interface/FrameXML/…`) is now a chain name rather than a
// filesystem join. `secret.txt` sits beside the AddOns root and is unreachable either way.
assert!(addon.read(&join_ref(src, "../../../secret.txt")).is_none());
assert!(addon
.read(&join_ref("Probe/src", "../../../secret.txt"))
.read(&join_ref("Interface/AddOns/Probe", "..\\secret.txt"))
.is_none());
assert!(addon.read(&join_ref("Probe", "..\\secret.txt")).is_none());
// A leading `/` is not "the filesystem root" — it re-roots at AddOns, which is the only
// root a FrameXML path has. So this looks for `<AddOns>/etc/hosts` and finds nothing.
assert!(
addon
.read(&join_ref(src, "../../../../../../../../etc/passwd"))
.is_none(),
"over-consuming `..` cannot walk out of the install either"
);
// A leading `/` is not "the filesystem root" — `join_ref` re-roots it at the base it is
// given, and with no base that is a bare relative name, which is not under
// `Interface/AddOns/` and so is a chain lookup that misses.
assert_eq!(join_ref("", "/etc/hosts"), "etc/hosts");
assert!(addon.read(&join_ref("", "/etc/hosts")).is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
/// **A `.toc`-listed `.lua` and an XML-referenced one get the SAME chunk name** — the defect
/// decision 2155 found, asserted from the outside because it is only visible from there.
///
/// Every Ace2-era library finds the addon it belongs to by splitting a `debugstack` frame on
/// `\AddOns\` — `AceDB-2.0.lua:742`'s `".-\n.-\\AddOns\\(.-)\\.*"`,
/// `FuBarPlugin-2.0.lua:602`'s `string.find(debugstack(6,1,0), "\\AddOns\\(.*)\\")`. While a
/// `Dir` addon's path space was the AddOns folder, a `<Script file=>` chunk was named
/// `@AtlasLoot\Core\AtlasLoot.lua` and that split found **nothing**: `SSHonor_Fu` called
/// `IsAddOnLoadOnDemand(nil)` and AtlasLoot's AceDB keyed itself on a raw traceback. 80 of the
/// 219-addon corpus ship at least one `<Script file=>`.
///
/// The reference names both the same way and the note says why: a `.toc` line resolves against
/// `Interface\AddOns\<Addon>\` and a bare `<Script file=X>` against `dirname(referrer)` —
/// the same directory — and the chunk name is `"@%s"` over the resolved path in both cases
/// (wow-re `ui/scratch/xml-toc-path-resolution.md` §1, `include-lua-dispatch.md` §7).
#[test]
fn an_xml_referenced_lua_is_named_like_a_toc_listed_one() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-chunkname-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("AddOns");
std::fs::create_dir_all(root.join("Probe/Core")).unwrap();
std::fs::write(
root.join("Probe/listed.lua"),
"LISTED = debugstack(1, 1, 0)",
)
.unwrap();
std::fs::write(
root.join("Probe/Core/viaxml.lua"),
"VIAXML = debugstack(1, 1, 0)",
)
.unwrap();
std::fs::write(
root.join("Probe/Core/doc.xml"),
r#"<Ui><Script file="viaxml.lua"/></Ui>"#,
)
.unwrap();
let toc = "## Interface: 11200\nlisted.lua\nCore\\doc.xml\n";
let addon = Addon {
name: "Probe".into(),
toc: Toc::parse(toc),
source: Source::Dir(root),
};
let script = UiScript::new().unwrap();
assert!(addon.load(&script).is_empty());
// The exact pattern the libraries run, on each file's own traceback.
let folder = |global: &str| -> Option<String> {
script
.eval::<Option<String>>(&format!(
"local _,_,f = string.find({global} or \"\", \"\\\\AddOns\\\\(.-)\\\\\") return f"
))
.unwrap()
};
assert_eq!(
folder("LISTED").as_deref(),
Some("Probe"),
"a manifest-listed file has always been named right"
);
assert_eq!(
folder("VIAXML").as_deref(),
Some("Probe"),
"and an XML-referenced one must be named the same way — this was nil"
);
// …and literally, so this cannot pass for some other reason: both chunks carry the
// client's own full virtual path, which is the thing `\AddOns\` is being split out of.
for g in ["LISTED", "VIAXML"] {
let frame = script.eval::<String>(&format!("return {g}")).unwrap();
assert!(
frame.contains("Interface\\AddOns\\Probe\\"),
"{g} is named after the install path, not the AddOns root: {frame}"
);
}
let _ = std::fs::remove_dir_all(&tmp);
}
/// A missing hard dependency drops exactly the dependent, and an optional one drops nothing.
///
/// This is the behaviour a flat topological sort cannot express, and the reason the walk is
@@ -329,3 +329,41 @@ fn combat_text_master_toggle_unregisters() {
assert!(none, "disabled: nothing paints ({:?})", s.errors());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`DAMAGE_TEXT_FONT` is a Lua global the engine reads, not a hardcoded face** (decision 2156).
///
/// `0x6c847c` is the only instruction in `WoW.exe` that reads it, through `FrameScript_GetText`'s
/// fast arm into a plain `lua_gettable(LUA_GLOBALSINDEX)` — so whatever is in the global when the
/// world-entry load edge closes is what the numbers draw in. Stock `Fonts.xml` puts Friz there;
/// MikScrollingBattleText and pfUI put their own face there at `ADDON_LOADED`, which the reference
/// reads because `0x6c8470` runs *after* the addon load, not at CRT init as three wow-re notes
/// had it.
///
/// Skips without client data.
#[test]
fn the_damage_text_font_is_read_from_the_lua_global() {
let data = benilla_formats::wow_data_or_skip!();
let _ = data;
let s = UiScript::new().unwrap();
// Before anything assigns it, there is nothing to bind — the reference's `0x704350` failure
// block leaves `0x703bf0`'s pre-seeded `""`, and an empty name is what the font factory's own
// guard rejects.
assert_eq!(crate::combat_text::read_damage_text_font(&s).0, None);
// Stock FrameXML, off the player's own chain: `Fonts.xml:6`.
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
assert_eq!(
crate::combat_text::read_damage_text_font(&s).0.as_deref(),
Some("Fonts\\FRIZQT__.TTF"),
);
// An addon assigning it at ADDON_LOADED — MikScrollingBattleText's line 255, in shape.
s.run(r#"DAMAGE_TEXT_FONT = "Interface\\AddOns\\MSBT\\Fonts\\Adventure.ttf""#)
.expect("the assignment runs");
assert_eq!(
crate::combat_text::read_damage_text_font(&s).0.as_deref(),
Some("Interface\\AddOns\\MSBT\\Fonts\\Adventure.ttf"),
"the addon's face, not Friz"
);
}
@@ -490,6 +490,18 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
));
}
}
// **`DAMAGE_TEXT_FONT` binds HERE, at the end of the load edge, and once** (decision 2156):
// `0x6c8470` runs from `0x401570 + 0x1620`, *after* the UI load `0x401602` — so after
// FrameXML's `Fonts.xml` and after every non-LoadOnDemand addon's `ADDON_LOADED`, which is
// where MikScrollingBattleText and pfUI assign it. The reference reads the global's value
// eagerly, hands it to the font factory, and never looks again: one writer of `[0xce8820]`,
// no invalidation, and a `/reloadui` does not re-run it.
//
// One step later than the reference, deliberately: it reads after `PLAYER_ENTERING_WORLD`
// too, which here fires from [`crate::ui_unit`] when the self descriptor lands rather than
// inside this call. Nothing in the corpus assigns a font that late, and moving the seat would
// mean waiting on the wire for a value the whole load edge has already settled.
world.insert_resource(crate::combat_text::read_damage_text_font(&script));
// The load edge is over: disarm the instruction bound `load_ingame_ui` installed (decision
// 1306). From here every OnUpdate and event handler runs unhooked — a session must not kill
// a player's addon for being slow; only a load that never returns is fair game.
+101 -47
View File
@@ -33,33 +33,21 @@
//! plates diagonal-linear without limit (§9, byte-closed) and the director rejected that
//! look; past 1024×768 the plate grows at half the real rate (midway between faithful and
//! the native size).
//! - **Anatomy — the §7 draw list, byte-verified** (frame offsets/sizes in gx screencoord
//! units; TEXT heights resolve through the same damped basis, [`text_px`];
//! back → front): the `Nameplate-Border` frame filling the 0.1 × 0.025 rect — its 128 × 32 art
//! **sharp-resampled** to the plate's exact size ([`border`], 0188) so it reads crisp, not
//! bilinear-magnified; the
//! `UI-TargetingFrame-BarFill` health bar (0.0804 × 0.007025 at BOTTOMLEFT + (0.0031,
//! 0.003125)) — fill = HEALTH/MAXHEALTH as a **left-anchored crop** (u1 = fraction), instant,
//! REACTION-tinted (hostile red / neutral yellow / friendly green / player pure blue — the
//! confirmed dwords), **no backing behind missing health** (the border alone), the BORDER
//! drawn over the fill (its rounded bevels cap the fill's ends — the reference look, a
//! director-pinned correction to the §7 order); the name (`NAMEPLATE_FONT` = Friz, h 0.01,
//! BOTTOM at plate CENTER, black 1 px drop shadow — director-tuned from the recorded ±0.001 gx,
//! no truncation) in WHITE; the level (h 0.0086 — director-pinned one em under the byte 0.009;
//! CENTER at BOTTOMRIGHT + (−0.0092, +0.0071)) in
//! the client's exact con dwords over its own grayband table; the skull
//! (`UI-TargetingFrame-Skull`, 0.01², OVERLAY on the level's anchor) replacing the number for
//! a world boss (creature rank 3, unconditional) or a hostile ≥ 10 levels up. **No cast bar** exists on 1.12 plates
//! (ctor-verified: exactly 7 children — the ctor's `Nameplate-Glow` child renders as the
//! bar brighten below, not as a drawn overlay).
//! - **Anatomy — not here any more.** The plate's six regions, its health-bar child, their
//! layers and the anchors between them live with the widgets that carry them
//! ([`benilla_ui::script::nameplate`], decision 2148): the plate is a real `Button` under the
//! `WorldFrame` and the shared frame→quad path draws it. What stays this file's is everything
//! about the WORLD — the gate above, the anchor, the projection, the seat, and the damped
//! basis the geometry is computed in ([`plate_basis`], [`gx_px`], [`text_px`]).
//! - **Highlight = the mouseover unit ∪ the target** (the watcher `0x606f20 → 0x607080` reads
//! both globals): the bar's own colour brightens — a uniform [`LIT_BOOST`] lift of the fill
//! tint, gradient untouched, nothing else changes (director-pinned form, 0184; the recorded
//! ADD `Nameplate-Glow` rim read as hard edge lines on our linear-blending pipeline and is
//! not drawn). The plate rect is itself mouse-enabled UI: hovering it makes its unit the
//! mouseover (OnEnter `0x7cb850` → `[0xb4e2c8]`; [`PlateRects`] feeds the shared [`Hovered`]
//! pick), which also lifts the model emissive and lets clicks select through the plate;
//! plate-rect hover additionally turns the name yellow `0xFFFFFF00` (OnEnter, name-only).
//! not drawn). The plate is itself mouse-enabled UI: hovering it makes its unit the mouseover
//! (OnEnter `0x7cb850` → `[0xb4e2c8]` — [`PlateHover`], read straight off the widget the
//! pointer landed on since 2159), which also lifts the model emissive, and a completed click
//! selects through the plate ([`PlateClicks`]); the hover additionally turns the name yellow
//! `0xFFFFFF00` (OnEnter, name-only).
//! All decoupled from the target dim below.
//! - **Target highlight**: relative alpha — with a target, the target's plate is opaque and
//! every other plate drops to `0x7F`; with no target all are opaque.
@@ -134,12 +122,27 @@ pub(crate) const CVAR_FRIENDS: &str = benilla_ui::script::CVAR_NAMEPLATE_FRIENDS
#[derive(Resource, Default)]
pub(crate) struct VPlates(pub(crate) EntityHashSet);
/// The plates' screen rects this frame, in push (= draw) order. The plate is mouse-enabled UI on
/// the reference (`RegisterForClicks` in the ctor; OnEnter `0x7cb850` sets the mouseover-unit
/// global), so the hover pick (`crate::target::hover`) consults these — last frame's layout, the
/// reference's own input-vs-layout latency — before ray-testing the world.
/// **The unit whose plate the pointer is inside** — the plate's own OnEnter publishing the
/// mouseover, from this side (`0x7cb850` → `[0xb4e2c8]`).
///
/// The plate is real mouse-enabled UI now (2148), so the UI pointer pass owns the cursor over it
/// and `target::hover`'s world pick correctly stands down; this is how the unit still reaches
/// [`crate::target::Hovered`]. Written by the plate driver from
/// [`benilla_ui::script::UiScript::hovered_nameplate`] — last frame's layout, which is the
/// reference's own input-vs-layout latency.
#[derive(Resource, Default)]
pub(crate) struct PlateRects(pub(crate) Vec<(Rect, Entity)>);
pub(crate) struct PlateHover(pub(crate) Option<Entity>);
/// Completed clicks on plates, waiting for the targeting chain — the reference's plate click slot
/// (`0x7cb910`), which ends in the same `SetSelection` a click on the body does.
///
/// Both a physical click and an addon's `plate:Click("LeftButton")` land here: the engine records
/// them at the one click funnel both go through.
#[derive(Resource, Default)]
pub(crate) struct PlateClicks {
pub(crate) left: Vec<Entity>,
pub(crate) right: Vec<Entity>,
}
/// The plate frame, gx screen-height units (`[0x87d9cc]`/`[0x87d9d0]`): 0.1 × 0.025. The border
/// SetAllPoints-fills it; everything else anchors inside it (§7, byte-verified offsets).
@@ -433,15 +436,18 @@ struct PlateWorld<'w, 's> {
}
/// Gate + draw, every frame: decide which units carry a plate (into [`VPlates`], the
/// name-exclusivity verdict) and append the §7 draw list — border, left-cropped reaction fill,
/// shadowed name (yellow under the mouse), con-colored level or the skull — at constant
/// screen size over the projected anchor + 2/3 yd. Runs in the [`UiQuadAppend`] window (after
/// name-exclusivity verdict), seat each one, and hand the result to the widget layer as
/// [`PlateState`] — at constant screen size over the projected anchor + 2/3 yd. Runs after
/// the script extract), after the targeting chain (it reads the frame's selection verdict).
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
fn drive_vplates(
mode: Res<VPlateMode>,
mut plates: ResMut<VPlates>,
mut rects: ResMut<PlateRects>,
mut plate_hover: ResMut<PlateHover>,
mut plate_clicks: ResMut<PlateClicks>,
// Camera freelook, for the mouselook toggle `0x60f830`: plates stop taking the mouse while
// the pointer is driving the camera.
rig: Res<crate::player::CameraControl>,
world: PlateWorld,
mut names: ResMut<NameCache>,
net_commands: Res<NetCommands>,
@@ -463,26 +469,61 @@ fn drive_vplates(
mut bucket: Local<crate::smart_rect::SmartBucket>,
// The raid-target board (decision 0434 §6) — the plate's raid-icon child reads it.
group: Res<crate::ui_party::GroupState>,
// Whether this VM's plates have been told about freelook (the `0x60f830` edge).
mut mouse_told: Local<crate::ui_script::VmMemo<Option<bool>>>,
) {
plates.0.clear();
rects.0.clear();
plate_hover.0 = None;
bucket.clear();
// **Every early return has to RETIRE the plates first, and that is new with 2148.** A painter
// could stop drawing and the plates were gone with the frame's quads; widgets stay until they
// are hidden, so a V press that turns plates off — or a camera-less frame, or a world exit —
// would otherwise leave the last frame's plates standing on screen forever. `sync` with no
// states is exactly the reference's own answer: `0x608a10` on every live plate, hiding each and
// returning it to the pool.
let retire_all = |script: Option<NonSendMut<benilla_ui::script::UiScript>>| {
if let Some(mut script) = script {
script.retire_nameplates();
}
};
if !mode.enemies && !mode.friends {
retire_all(script);
return;
}
let (Ok((cam, cam_pose)), Ok((self_tf, self_store)), Some(mut script)) =
(world.camera.single(), world.self_q.single(), script)
let (Ok((cam, cam_pose)), Ok((self_tf, self_store))) =
(world.camera.single(), world.self_q.single())
else {
retire_all(script);
return;
};
let Some(mut script) = script else {
return;
};
let cam_tf = GlobalTransform::from(*cam_pose);
let Some(viewport) = cam.logical_viewport_size() else {
script.retire_nameplates();
return;
};
let basis = plate_basis(viewport);
let gx = |v: f32| gx_px(v, basis);
let window = world.window.single().ok();
let cursor = window.and_then(|w| w.cursor_position());
// **The mouselook toggle** (`0x60f830`, called from `0x483e80`/`0x483e70`): plates stop taking
// the mouse while the camera is in freelook, so a right-drag that starts over a plate turns the
// camera instead of clicking the plate, and the plates are not holding a pointer that has left
// the screen. Written on the EDGE, not per frame — the reference's own is two call sites on the
// freelook transitions, and the memo is keyed to the VM because that is what it is memory about
// (1290). A fresh VM's plates are born with the bit, so the memo starting empty is right.
// Which unit's plate the pointer is inside (last frame's layout), and the completed clicks
// waiting on the targeting chain. Both come from the plate widgets themselves, which is the
// reference's own arrangement: the plate's OnEnter publishes the mouseover and its click slot
// ends in `SetSelection`.
let looking = rig.is_looking();
if *mouse_told.get(&script) != Some(looking) {
*mouse_told.get(&script) = Some(looking);
script.set_nameplate_mouse(!looking);
}
let hovered_key = script.hovered_nameplate();
let clicked = script.take_nameplate_clicks();
let my_level = self_store.and_then(|s| s.0.unit_level()).unwrap_or(1);
let has_target = world.selection.target.is_some();
@@ -640,8 +681,8 @@ fn drive_vplates(
// plate inside every screen border (`SetPoint(TOP ← root.BOTTOMLEFT, clampedX/Y)`).
// The highlight is a 2-D hover over THIS rect (the frame's OnEnter — yellow name). `pw`/`ph`
// (the plate's logical size) are hoisted above the loop — the border resample keys off them.
// Geometry trace for the vplates capture (`WOW_VPLATE_TRACE=1`): the exact quad rects
// pushed this frame, in logical px — the machine-side check the capture PNG can't give
// Geometry trace for the vplates capture (`WOW_VPLATE_TRACE=1`): the exact plate rects
// this frame, in logical px — the machine-side check the capture PNG can't give
// (fill/border/text hues overlap under zoom).
let trace = std::env::var("WOW_VPLATE_TRACE").as_deref() == Ok("1");
// The full seat (`0x509ec0`): the desired rect TOP-anchored on the raw projected point
@@ -699,12 +740,13 @@ fn drive_vplates(
);
}
bucket.claim(plate);
rects.0.push((plate, entity));
let hover = cursor.is_some_and(|c| plate.contains(c));
// The plate's own hover, from last frame's layout: the widget layer answers which unit's
// plate the pointer is inside, and the driver resolves it to this frame's entity below.
let hover = hovered_key == Some(guid.0);
// The highlight trigger — the watcher's OR over the two globals: this unit is the
// MOUSEOVER (the 3-D body pick, or a plate hover routed through [`PlateRects`] last
// frame) or the current TARGET. `hover` (this frame's rect) joins in so the bar
// brighten never lags the yellow name.
// MOUSEOVER (the 3-D body pick, or the plate's own hover — [`PlateHover`], which the
// widget layer answered above) or the current TARGET. `hover` joins in directly so the
// bar brighten never lags the yellow name by the frame the mouseover takes to publish.
let lit =
hover || world.hovered.target == Some(entity) || world.selection.target == Some(entity);
if trace {
@@ -732,6 +774,17 @@ fn drive_vplates(
let mark = group.raid_target_index(guid.0);
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
let y_units = (viewport.y - plate.min.y) / seam;
if hovered_key == Some(guid.0) {
plate_hover.0 = Some(entity);
}
for click in &clicked {
if click.key == guid.0 {
match click.button.as_str() {
"RightButton" => plate_clicks.right.push(entity),
_ => plate_clicks.left.push(entity),
}
}
}
states.push(PlateState {
// The plate's identity is the UNIT's, for the life of the plate — the reference's
// `[unit+0xe60]` binding, which every addon's per-plate cache rests on.
@@ -812,7 +865,8 @@ impl Plugin for VPlatesPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<VPlateMode>()
.init_resource::<VPlates>()
.init_resource::<PlateRects>()
.init_resource::<PlateHover>()
.init_resource::<PlateClicks>()
.add_systems(
Update,
(
@@ -836,9 +890,9 @@ impl Plugin for VPlatesPlugin {
.in_set(VPlateSet),
)
// **Outside [`VPlateSet`] deliberately.** Its only ordering need is to be ahead of the
// script tick, and three sets order *after* `VPlateSet` while `drive_vplates` sits in
// `UiQuadAppend` — pulling the whole set in front of `UiInput` to carry one system
// would rewire all of that. A V press can therefore reach the globals a frame late,
// script tick, and three sets order *after* `VPlateSet` while `drive_vplates` runs
// after the targeting chain — pulling the whole set in front of `UiInput` to carry one
// system would rewire all of that. A V press can therefore reach the globals a frame late,
// which costs nothing: their only readers are `UpdateNameplates` at the two world-entry
// events and an addon that calls it, never a per-frame path.
.add_systems(Update, feed_plate_globals.before(crate::ui_script::UiInput));
+141 -3
View File
@@ -44,6 +44,12 @@ pub struct AttackerState {
pub resist: i32,
/// `BlockedAmount` — the trailing blocked-damage word.
pub blocked: u32,
/// `meleeSpellId` (`rec+0xac`) — **a boolean in practice, not an id.** Both of the client's
/// readers only compare it to zero (`0x625e40`, and `0x6246ca`'s `xor/cmp/sbb/neg` feeding
/// the swing-animation selector), so in this packet it means "this swing came from a spell".
/// Nonzero for an on-next-swing ability — Heroic Strike is the one the field was first
/// noticed on.
pub melee_spell_id: u32,
/// The **first** sub-damage block's school (`SpellSchools`: 0 physical … 6 arcane; vmangos
/// writes `GetFirstSchoolInMask(subDamage->damageSchoolMask)`), `0` when the swing carried no
/// sub-damage at all.
@@ -54,11 +60,44 @@ pub struct AttackerState {
pub school: u8,
}
impl AttackerState {
/// `0x625e40` — **whether this swing is displayed at all**, in either of the two places that
/// display one. Twenty-six bytes:
///
/// ```text
/// 625e40 mov edx, [ecx+0xac] ; meleeSpellId
/// 625e46 test edx, edx
/// 625e48 mov eax, 1
/// 625e4d je 0x625e59 ; id == 0 -> TRUE
/// 625e4f cmp [ecx+0xa4], eax ; victimState vs 1
/// 625e55 je 0x625e59 ; state == 1 -> TRUE
/// 625e57 xor eax, eax ; otherwise -> FALSE
/// ```
///
/// **Two callers, and they are the two display paths**: `0x629b3e` in `0x629b10`, which skips
/// the whole chat dispatcher `0x629b60` — no line, no trailer, no `COMBAT_TEXT_UPDATE` — and
/// `0x62440d`, the first thing the **floating-combat-text builder** `0x6243e0` does, jumping
/// straight to its own epilogue. So a Heroic Strike that is dodged or parried produces neither
/// a combat-log line nor a floating number.
///
/// **Untouched by it:** the swing animation (`0x6246a0`), the swing timers and autorepeat, the
/// auto-target arm, the tutorial trigger, and everything `0x624530` does after its call to
/// `0x6243e0` — the wound flinch, the blood spurt and the whole victimState-keyed sound block.
/// A suppressed swing still swings, still connects, still sounds.
///
/// A landing ability (`victimState == 1`) is unaffected whatever its spell id, which is why
/// ordinary Heroic Strike damage reads normally.
#[must_use]
pub fn displayed(&self) -> bool {
self.melee_spell_id == 0 || self.victim_state == 1
}
}
/// Read `SMSG_ATTACKERSTATEUPDATE` (byte-verified order, attacker **PackGUID first** — settled by
/// the wow-re §5 against the handler's downstream use, decision 0073): HitInfo · attacker PackGUID ·
/// victim PackGUID · TotalDamage · SubDamageCount + per-sub `{school u32, damage f32, damage u32,
/// absorb u32, resist i32}` · TargetState · two u32s (zero + "spell id, seen with heroic strike") ·
/// BlockedAmount.
/// BlockedAmount. TargetState is then normalized the way the handler normalizes it (`0x625e35`).
pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState> {
let hit_info = read_u32_le(r)?;
let attacker = read_packed_guid(r)?;
@@ -82,10 +121,29 @@ pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState
resist += read_u32_le(r)? as i32;
}
let victim_state = read_u32_le(r)?;
let _zero = read_u32_le(r)?;
let _spell_id = read_u32_le(r)?;
// vmangos names the tail `victimState · attackerState · meleeSpellId · blockedAmount`
// (`Server/Packets/Combat.cpp:81`), and the client's parse `0x625c60` stores them at
// `rec+0xa4`/`+0xa8`/`+0xac`/`+0xb0` in that order. `attackerState` is read by **nothing** in
// the whole combat TU; `meleeSpellId` is read twice and only ever compared to zero.
let _attacker_state = read_u32_le(r)?;
let melee_spell_id = read_u32_le(r)?;
let blocked = read_u32_le(r)?;
// **The handler does not trust the wire's TargetState for a full block** (`0x625e35`): a swing
// that got no damage through while blocking something is forced to `VICTIMSTATE_BLOCKS`,
// upstream of every consumer, which is why the display dispatcher's flag table has a `1` at an
// index its own earlier arm makes unreachable. Normalized here, in the same position — before
// the chat line, the floating word and the sound each read it.
//
// Against vmangos it is belt-and-braces: `Unit.cpp`'s `MELEE_HIT_BLOCK` arm already sets
// `VICTIMSTATE_BLOCKS` when `blocked_amount >= subDamage[0].damage`. It costs three lines and
// removes the client's dependence on a server doing so.
let victim_state = if damage == 0 && blocked != 0 {
5
} else {
victim_state
};
Ok(AttackerState {
melee_spell_id,
attacker,
victim,
hit_info,
@@ -147,3 +205,83 @@ pub(super) fn read_ai_reaction(r: &mut impl Read) -> io::Result<(u64, u32)> {
pub fn attack_swing(guid: u64) -> Vec<u8> {
guid.to_le_bytes().to_vec()
}
#[cfg(test)]
mod tests {
use super::read_attacker_state;
/// One swing on the wire, as `SMSG_ATTACKERSTATEUPDATE` lays it out.
fn packet(damage: u32, victim_state: u32, blocked: u32) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&0u32.to_le_bytes()); // HitInfo
b.extend_from_slice(&[0x01, 0x11]); // attacker PackGUID
b.extend_from_slice(&[0x01, 0x22]); // victim PackGUID
b.extend_from_slice(&damage.to_le_bytes()); // TotalDamage
b.push(1); // one sub-damage block
b.extend_from_slice(&0u32.to_le_bytes()); // school
b.extend_from_slice(&(damage as f32).to_le_bytes());
b.extend_from_slice(&damage.to_le_bytes());
b.extend_from_slice(&0u32.to_le_bytes()); // absorb
b.extend_from_slice(&0u32.to_le_bytes()); // resist
b.extend_from_slice(&victim_state.to_le_bytes()); // TargetState
b.extend_from_slice(&0u32.to_le_bytes()); // zero
b.extend_from_slice(&0u32.to_le_bytes()); // spell id
b.extend_from_slice(&blocked.to_le_bytes()); // BlockedAmount
b
}
/// `0x625e40` — a swing that came from a spell and did not land plainly is not displayed at
/// all, in either of the two places a swing is displayed.
///
/// Heroic Strike is the case: it rides `SMSG_ATTACKERSTATEUPDATE` with a nonzero
/// `meleeSpellId`, so when it lands (`victimState == 1`) it reads exactly like a white hit,
/// and when it is dodged or parried the reference prints **no combat-log line and floats no
/// word**. An ordinary swing carries id 0 and is never touched by this.
#[test]
fn a_swing_from_a_spell_that_did_not_land_is_not_displayed() {
let with_id = |state: u32| {
let mut b = packet(0, state, 0);
let at = b.len() - 8; // the meleeSpellId slot, before BlockedAmount
b[at..at + 4].copy_from_slice(&78u32.to_le_bytes()); // Heroic Strike
read_attacker_state(&mut b.as_slice()).expect("parse")
};
// Dodged, parried, missed outright: suppressed.
for state in [0, 2, 3, 4] {
assert!(
!with_id(state).displayed(),
"spell swing, victimState {state}"
);
}
// Landed: displayed like any other hit.
assert!(
with_id(1).displayed(),
"a landing Heroic Strike still shows"
);
// A plain swing carries id 0 and is displayed whatever the victim did.
for state in [0, 1, 2, 3, 4] {
let s = read_attacker_state(&mut packet(0, state, 0).as_slice()).expect("parse");
assert!(s.displayed(), "plain swing, victimState {state}");
}
}
/// `0x625e35` — a swing that got nothing through while blocking something **is** a block, and
/// the handler says so before any consumer reads the field.
///
/// It matters because the display dispatcher's `victimState == 5` arm is what words `VSBLOCK`,
/// and its else-arm emits no line at all for the `1` the wire might otherwise carry — so
/// without this normalization a fully-blocked swing could go silent instead of reading
/// "X blocks your attack."
#[test]
fn a_fully_blocked_swing_is_normalized_to_victimstate_blocks() {
let s = read_attacker_state(&mut packet(0, 1, 90).as_slice()).expect("parse");
assert_eq!(s.victim_state, 5, "no damage through, something blocked");
// A partial block lands damage, so the wire's own state stands.
let s = read_attacker_state(&mut packet(50, 1, 40).as_slice()).expect("parse");
assert_eq!(s.victim_state, 1);
// A dodge blocks nothing and keeps its state, damage or not.
let s = read_attacker_state(&mut packet(0, 2, 0).as_slice()).expect("parse");
assert_eq!(s.victim_state, 2);
}
}
+14 -1
View File
@@ -133,7 +133,20 @@ impl std::error::Error for Error {
/// nothing for the reference's record to key on. Defensive rather than observed — all 228 of the
/// client's own are named.
pub fn parse(text: &str) -> Result<Vec<AddonBinding>, Error> {
let doc = roxmltree::Document::parse(text).map_err(Error::Xml)?;
// Namespace-oblivious, like the FrameXML document layer beside it and for the same reason
// (decision 2155; `framexml::parse`'s comment is the mechanism): a `Bindings.xml` is read by
// the same `XMLTree.cpp` tree the client builds with expat's `XML_ParserCreate`, which has no
// namespace processing at all — so an undeclared prefix cannot be an error there, and must not
// cost the whole file here.
let repaired;
let doc = match roxmltree::Document::parse(text) {
Ok(doc) => doc,
Err(roxmltree::Error::UnknownNamespace(..)) => {
repaired = crate::framexml::bind_undeclared_prefixes(text);
roxmltree::Document::parse(&repaired).map_err(Error::Xml)?
}
Err(e) => return Err(Error::Xml(e)),
};
let mut out = Vec::new();
for node in doc.root_element().descendants() {
if !node.is_element() || !node.tag_name().name().eq_ignore_ascii_case("Binding") {
+215 -1
View File
@@ -183,7 +183,39 @@ impl std::error::Error for Error {
/// error: the RE spec never states the loader checks the root's own tag name, only that it walks
/// its children (`0x6ede10`).
pub fn parse(text: &str) -> Result<ParsedDocument, Error> {
let doc = roxmltree::Document::parse(text).map_err(Error::Xml)?;
// **The reference has no XML Namespaces, and our stand-in parser does** (decision 2155).
//
// The client's document tree is `XMLTree.cpp`'s, built by embedded **expat 1.95.5** created
// through `XML_ParserCreate 0x7e6690` — *not* `XML_ParserCreateNS` — so namespace processing
// is off: a prefix is opaque bytes inside the attribute's name, an `xmlns:` declaration is an
// ordinary attribute, and an *undeclared* prefix cannot be an error because nothing is
// resolving one. The node struct the FrameXML loader walks is that same tree (name `@+0x8`,
// the `{char*, char*}` attribute array `@+0x14`, looked up by `GetAttribute 0x6f2cf0`'s linear
// case-insensitive scan — `scratch/rf24-framexml-loader.md`'s header and
// `scratch/simplehtml-markup-engine.md` §1.2).
//
// `roxmltree` is namespace-aware and has no switch for it, so it refuses
// `<Ui xsi:schemaLocation="…">` with `UnknownNamespace` when the file never declares
// `xmlns:xsi` — and takes the **whole document** with it. That is not a rare authoring slip:
// Auctioneer writes exactly that line at the top of all five of its UI documents and
// BeanCounter includes two of them, so the Auction House replacement lost its entire interface
// to a concept the client it targets does not implement.
//
// So a namespace error is answered by giving the parser the binding it wants and re-reading —
// driven by the prefix the error itself names, never by scanning the text for colons (which
// would fire inside comments, CDATA and attribute values). The document's own bytes are
// otherwise untouched, and [`element_from_node`] drops every prefixed attribute, so nothing
// downstream can see the difference between a repaired document and one that never had a
// prefix.
let repaired;
let doc = match roxmltree::Document::parse(text) {
Ok(doc) => doc,
Err(roxmltree::Error::UnknownNamespace(..)) => {
repaired = bind_undeclared_prefixes(text);
roxmltree::Document::parse(&repaired).map_err(Error::Xml)?
}
Err(e) => return Err(Error::Xml(e)),
};
let root = doc.root_element();
let mut warnings = Vec::new();
@@ -238,6 +270,98 @@ pub fn parse(text: &str) -> Result<ParsedDocument, Error> {
Ok(ParsedDocument { items, warnings })
}
/// Declare every prefix the document uses but never binds, so a namespace-aware parser stops
/// having an opinion the reference's parser cannot have (decision 2155; the *why* is in
/// [`parse`]).
///
/// **Driven by the parser's own error, not by a scan.** `roxmltree` names the offending prefix in
/// `UnknownNamespace`, so each round adds exactly that one binding to the ROOT element's start tag
/// — where a declaration is in scope for the whole document, including the root's own name — and
/// re-parses. A text scan for `name:` would fire inside comments, CDATA and attribute values
/// (`Interface\AddOns\…` and `$Id:` lines are everywhere in this corpus), and would have to
/// re-implement the tokenizer to avoid it.
///
/// Bounded, and honest when it gives up: a document that keeps naming new prefixes stops after
/// [`MAX_BOUND_PREFIXES`] rounds and the text is returned as it stands, so [`parse`] reports the
/// parser's real error rather than looping. A document whose error is something else on the retry
/// (a genuinely malformed file that also has a stray prefix) reports that error, which is the one
/// worth reading.
pub(crate) fn bind_undeclared_prefixes(text: &str) -> String {
let mut out = text.to_string();
for _ in 0..MAX_BOUND_PREFIXES {
let Err(roxmltree::Error::UnknownNamespace(prefix, _)) = roxmltree::Document::parse(&out)
else {
return out;
};
let Some(at) = root_attr_insertion_point(&out) else {
return out;
};
// The URI is inert and never read: nothing downstream resolves a namespace, and
// `element_from_node` drops prefixed attributes outright. It only has to be *a* URI, and
// distinct per prefix so two stray prefixes are not silently the same namespace.
out.insert_str(
at,
&format!(" xmlns:{prefix}=\"urn:benilla:undeclared:{prefix}\""),
);
}
out
}
/// How many distinct undeclared prefixes [`bind_undeclared_prefixes`] will bind before it stops.
///
/// Generous against the real cause (one file, one stray `xsi:`) and small enough that a
/// pathological document cannot turn a failed parse into a long loop.
const MAX_BOUND_PREFIXES: usize = 8;
/// The byte offset inside the ROOT element's start tag at which a new attribute may be inserted —
/// just before the `>` or `/>` that closes it.
///
/// Walks the prolog by hand rather than parsing, because the document by definition does not parse
/// at this point: `<?…?>`, `<!--…-->` and `<!…>` are skipped, and the first remaining `<` opens the
/// root. Inside that tag, `'` and `"` runs are stepped over whole, so a `>` living in an attribute
/// value (`hyperlinkFormat="|H%s|h[%s]|h"` is the shape that exists in this corpus) cannot be
/// mistaken for the tag's end.
fn root_attr_insertion_point(text: &str) -> Option<usize> {
let b = text.as_bytes();
let mut i = 0usize;
while i < b.len() {
if b[i] != b'<' {
i += 1;
continue;
}
let rest = &text[i..];
if rest.starts_with("<?") {
i += rest.find("?>").map(|n| n + 2)?;
} else if rest.starts_with("<!--") {
i += rest.find("-->").map(|n| n + 3)?;
} else if rest.starts_with("<!") {
i += rest.find('>').map(|n| n + 1)?;
} else {
// The root's start tag. Step over quoted runs so a `>` inside a value is not its end.
let mut j = i + 1;
while j < b.len() {
match b[j] {
q @ (b'\'' | b'"') => {
j += 1;
while j < b.len() && b[j] != q {
j += 1;
}
}
b'>' => {
// Insert before a `/>`'s slash, not between the slash and the `>`.
let at = if j > 0 && b[j - 1] == b'/' { j - 1 } else { j };
return Some(at);
}
_ => {}
}
j += 1;
}
return None;
}
}
None
}
fn attr_ci(node: roxmltree::Node, name: &str) -> Option<String> {
node.attributes()
.find(|a| a.name().eq_ignore_ascii_case(name))
@@ -274,6 +398,14 @@ fn element_from_node(node: roxmltree::Node) -> Element {
tag: node.tag_name().name().to_string(),
attrs: node
.attributes()
// **A prefixed attribute is unreachable in the reference, so it is not an attribute
// here** (decision 2155). `GetAttribute 0x6f2cf0` compares a lookup key against the
// stored name whole, and no FrameXML attribute name contains a colon — so
// `xsi:schemaLocation` can never match any key the loader asks for. Dropping it is
// what keeps a *declared* prefix (stock FrameXML's own `<Ui xmlns:xsi=… xsi:schema
// Location=…>`) and an *undeclared* one (Auctioneer's) behave identically, and stops
// `roxmltree`'s local-name view from making `xsi:name` answer a lookup for `name`.
.filter(|a| a.namespace().is_none())
.map(|a| (a.name().to_string(), a.value().to_string()))
.collect(),
children: node
@@ -504,6 +636,88 @@ pub fn resolve_name(raw: &str, parent_name: &str) -> String {
mod tests {
use super::*;
/// **An undeclared namespace prefix is not an error, because the reference has no namespaces**
/// (decision 2155). The client's tree is `XMLTree.cpp`'s, built by expat 1.95.5 through
/// `XML_ParserCreate 0x7e6690` — never `XML_ParserCreateNS` — so nothing is resolving a prefix
/// and nothing can find one unbound.
///
/// The literal line is Auctioneer's, at the top of all five of its UI documents (and two of
/// them are `<Include>`d by BeanCounter): a `roxmltree` `UnknownNamespace` there cost the
/// Auction House replacement its entire interface, an element at a time, to a concept the
/// client it targets does not implement.
#[test]
fn an_undeclared_namespace_prefix_does_not_cost_the_document() {
let doc = parse(
r#"<Ui xsi:schemaLocation="http://www.blizzard.com/wow/ui/">
<Frame name="Survived"/>
</Ui>"#,
)
.expect("the reference parses this, so we must");
assert_eq!(doc.items.len(), 1);
let TopLevel::Instance(el) = &doc.items[0] else {
panic!("{:?}", doc.items[0])
};
assert_eq!(el.name(), Some("Survived"));
}
/// Two stray prefixes, and one of them on an ELEMENT rather than an attribute — the binding
/// goes on the root, where it is in scope for the whole document including the root's own
/// name, and the retry loop is driven by the parser naming each prefix in turn.
#[test]
fn several_undeclared_prefixes_are_all_bound() {
let doc = parse(
r#"<Ui xsi:schemaLocation="x" foo:bar="y">
<Frame name="A"/>
<bar:Frame name="B"/>
</Ui>"#,
)
.expect("every stray prefix is bound, not just the first");
assert_eq!(doc.items.len(), 2);
}
/// **A prefixed attribute is not an attribute** — `GetAttribute 0x6f2cf0` compares a lookup key
/// against the stored name whole, and no FrameXML key contains a colon, so `xsi:name` can never
/// answer a lookup for `name`. Asserted on a *declared* prefix as well, because stock FrameXML
/// writes one on every `<Ui>` and the two spellings must behave identically.
#[test]
fn a_prefixed_attribute_never_answers_an_unprefixed_lookup() {
for text in [
r#"<Ui><Frame xsi:name="Ghost" text="real"/></Ui>"#,
r#"<Ui xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Frame xsi:name="Ghost" text="real"/>
</Ui>"#,
] {
let doc = parse(text).unwrap();
let TopLevel::Instance(el) = &doc.items[0] else {
panic!()
};
assert_eq!(el.name(), None, "a prefixed name= is unreachable: {text}");
assert_eq!(el.attr("text"), Some("real"), "{text}");
}
}
/// A genuinely malformed document still fails — the repair answers `UnknownNamespace` and
/// nothing else, so a mismatched tag is still the error a reader gets.
#[test]
fn a_malformed_document_still_fails_with_its_own_error() {
assert!(parse(r#"<Ui xsi:a="b"><Frame></Ui>"#).is_err());
}
/// The insertion point is found in the ROOT's start tag, past a prolog and past a `>` that
/// lives inside an attribute value — the shape `hyperlinkFormat="|H%s|h[%s]|h"` has in this
/// corpus, and the reason this walks quotes rather than searching for the first `>`.
#[test]
fn the_root_tag_is_found_past_a_prolog_and_a_quoted_angle_bracket() {
let doc = parse(
"<?xml version=\"1.0\"?>\n<!-- a > in a comment -->\n\
<Ui xsi:a=\"b\" note=\"a &gt; b\"><Frame name=\"Ok\"/></Ui>",
)
.unwrap();
assert_eq!(doc.items.len(), 1);
// …and a self-closing root: the attribute goes before the slash, not between it and `>`.
assert!(parse(r#"<Ui xsi:a="b"/>"#).is_ok());
}
#[test]
fn top_level_order_preserves_script_and_element_interleave() {
let doc = parse(
+30 -1
View File
@@ -12,8 +12,37 @@ impl Loader<'_> {
if el.attr_bool("hidden") {
self.call(wrapper, "Hide", (), dbg);
}
// **An unrecognised `frameStrata=` WARNS and skips; it does not raise** (decision 2160).
//
// The two doors are genuinely different in the reference and this is the one that is
// quiet. `CSimpleFrame::LoadXML 0x769820` resolves the name through `0x6f17d0`, whose miss
// arm returns `eax = 0` without writing the caller's out-parameter (`0x6f17ff`); the miss
// leg then pushes `0x878658 "Frame %s: Unknown frame strata: %s"` at severity 1 into the
// document's `CStatus` sink (`0x7699a4 call [edx+0xc]`, which returns — all five `Add`
// implementations image-wide are raise-free) and **reconverges with the hit path at
// `0x7699ad`**, carrying straight on into `frameLevel` and the rest of the subtree.
// `SetFrameStrata 0x76a470` is called only on the hit arm (`0x769971`), so the frame keeps
// whatever stratum it already had — its template's, its parent's, or the ctor's MEDIUM
// (`[+0xc0] = 3` at `0x7690c2`).
//
// The **Lua** binding is the loud one and stays as it is: `0x774360`'s miss reaches
// `0x774456 call 0x6f4940` (`luaL_error`), whose callee chain `luaG_errormsg 0x6fc780` /
// `luaD_throw 0x6f5d80` contains no `ret` at all — the epilogue after it is dead code.
//
// (wow-re `ui/scratch/frame-strata-miss-law.md`, §5 trio + orchestrator arbitration.)
//
// Resolving here rather than letting `SetFrameStrata` raise is what reproduces that split:
// routed through `self.call`, a bad value became a `report.errors` row, which is a script
// error the player sees — and it cost `EQL3` its whole `EQL3_Log.xml`, on a `<Frame
// frameStrata="ARTWORK">` (a draw-LAYER name) the real client shrugs at.
if let Some(strata) = el.attr("frameStrata") {
self.call(wrapper, "SetFrameStrata", strata.to_string(), dbg);
if crate::script::object::strata_from_str(strata).is_some() {
self.call(wrapper, "SetFrameStrata", strata.to_string(), dbg);
} else {
self.report
.warnings
.push(format!("{dbg}: Unknown frame strata: {strata}"));
}
}
if let Some(level) = el.attr("frameLevel") {
if let Ok(n) = level.parse::<i64>() {
+48 -9
View File
@@ -38,12 +38,14 @@
//! ([`join_ref`]) and hands the provider one already-resolved path; the provider decides what that
//! path is allowed to reach. [`load`] starts at the root, [`load_in`] starts at a named directory.
//!
//! A path with **no provider hit is an error**, not a warning: by [`LoadReport`]'s own definition
//! errors are "things that dropped a frame or a handler", and an unresolved `<Include>` drops a
//! whole document while an unresolved `<Script file=>` drops every handler in it. It was a warning
//! until 1186, and the cost was that an addon which resolved *nothing* reported success — Bagnon
//! missed all eleven of its references and came back with zero errors. The load still continues
//! (0068: the client logs and carries on); only the reporting changed.
//! A path with **no provider hit gets its own list**, [`LoadReport::missing_files`] — neither a
//! warning nor an error (decision 2155). It was a warning until 1186, and the cost was that an
//! addon which resolved *nothing* reported success — Bagnon missed all eleven of its references
//! and came back with zero errors. 1186 answered that by calling it an error, which fixed the
//! visibility and got the *severity* wrong the other way: the reference logs `"Couldn't open %s"`
//! and carries on, with nothing raised, so an addon shipping an incomplete package was scoring as
//! a client script error and reaching the player's red error dialog. Both facts are true and they
//! need two different lists. The load continues either way (0068: the client logs and carries on).
//!
//! ## MAXCSTACK discipline (decision 0068, probe A)
//!
@@ -78,9 +80,36 @@ pub struct LoadReport {
/// Things that dropped a frame or a handler: an unknown frame type, a handler that failed to
/// compile, a method call that errored, a malformed included document.
pub errors: Vec<String>,
/// **A named file the provider does not have** — an `<Include file=>` or `<Script file=>` whose
/// resolved path hit nothing (decision 2155).
///
/// Its own list because it is its own severity, and 1186 put it in the wrong one. The reference
/// does not raise here: `0x6edaa0` logs `"Couldn't open %s"` (`0x846ff4`) and returns null, the
/// `<Include>` arm never tests the recursion's result (`0x6ee00d` → `0x6ee012`), and the
/// `<Script>` leg reports `"Error loading %s"` (`0x872e50`) and returns 0 — every failure leg
/// reports through the sink and returns normally, with no throw and no `longjmp`
/// (wow-re `ui/scratch/xml-toc-path-resolution.md` §4 and `include-lua-dispatch.md` §7, both
/// VERIFIED). This is the same rule 2107 unified for the `.toc` walk and the demand load, at
/// the two doors 2107 did not reach.
///
/// **1186's finding is preserved and it is why this is not simply folded into
/// [`Self::warnings`]:** a document that resolved *nothing* must not report success — Bagnon
/// missed all eleven of its references and came back with zero errors. It still reports them,
/// under a name that says which kind of failure it is, and the caller decides severity by
/// whose manifest lied (`ui_script::addons`: ours is an `error!`, a player's addon a `warn!`,
/// both retained where the player can read them and neither raising a script error).
pub missing_files: Vec<String>,
/// How many frame instances were successfully created (`CreateFrame` returned a wrapper) — the
/// coverage number the real-file smoke test reports.
pub frames: usize,
/// **The loader's trace lines, emitted only while `FrameXML_Debug` is on** (decision 2160).
///
/// Empty in every normal load, which is the reference's own default: `[0xceea30]` boots at 0
/// and each of the loader's five trace sites is gated `flag > 0` (`0x6ee298 jle`). Its own
/// list rather than a `warnings` row because a trace is not a defect — the reference files it
/// into the same per-document record at severity **0**, one below the `frameStrata` warning's
/// severity 1.
pub traces: Vec<String>,
}
/// Materialize a parsed FrameXML document into live frames in `script`.
@@ -92,7 +121,8 @@ pub struct LoadReport {
/// instance is expanded ([`crate::framexml::expand`]) and materialized.
///
/// `files` is the engine-free seam (see module docs): it resolves a FrameXML/Lua path to its
/// **bytes**. Returning `None` yields a warning, not an error.
/// **bytes**. Returning `None` yields a [`LoadReport::missing_files`] row — reported, but not an
/// error, because nothing raised (decision 2155).
pub fn load(
script: &UiScript,
doc: &ParsedDocument,
@@ -513,7 +543,7 @@ impl Loader<'_> {
.push(format!("<Script file=\"{path}\">: {e}"));
}
}
None => self.report.errors.push(format!(
None => self.report.missing_files.push(format!(
"<Script file=\"{path}\">: no provider hit for \"{joined}\"; \
every handler in it is missing"
)),
@@ -565,7 +595,7 @@ impl Loader<'_> {
pub(super) fn do_include(&mut self, path: &str) {
let joined = join_ref(self.base(), path);
let Some(bytes) = (self.files)(&joined) else {
self.report.errors.push(format!(
self.report.missing_files.push(format!(
"<Include file=\"{path}\">: no provider hit for \"{joined}\"; the whole \
document it names is missing"
));
@@ -813,6 +843,15 @@ impl Loader<'_> {
.clone()
.unwrap_or_else(|| format!("<{}>", el.tag));
// The one trace site walked to the bytes — `Instantiate 0x6ee280`'s
// `0x871154 "-- Creating %s named %s"`, gated `flag > 0` at `0x6ee298` (decision 2160).
// The kind is the element tag, which is what the reference's first `%s` carries.
if self.model().framexml_debug.get() > 0 {
self.report
.traces
.push(format!("-- Creating {} named {dbg_name}", el.tag));
}
// This frame's own name is what its *contents* (regions, nested frames) substitute `$parent`
// against (rf27: a region/child's parent is this frame); a nameless frame passes the nearest
// named ancestor through unchanged. This frame's *own* anchors, by contrast, substitute
+143 -13
View File
@@ -425,20 +425,129 @@ mod loader_tests {
.unwrap());
}
/// A missing include is an **error**, and the rest of the load still proceeds.
/// **An unrecognised `frameStrata=` warns and skips; the frame keeps the stratum it had, and
/// the rest of the document loads** (decision 2160).
///
/// It was a warning until decision 1186. The load-and-continue half is unchanged and faithful
/// (0068: the client logs and carries on) — what changed is the *reporting*, because a warning
/// is not in the value callers assert on. Bagnon missed all eleven of its references and came
/// back with zero errors, which read as a clean load of an addon that had built nothing.
/// The two doors differ in the reference and this is the quiet one. `CSimpleFrame::LoadXML
/// 0x769820` resolves through `0x6f17d0`, whose miss returns 0 without writing the out-param;
/// the miss leg pushes `"Frame %s: Unknown frame strata: %s"` at severity 1 into the document
/// sink (`0x7699a4`, a call that returns) and reconverges with the hit path at `0x7699ad`,
/// never reaching `SetFrameStrata 0x76a470`. `EQL3`'s `EQL3_Log.xml` carries the literal case:
/// `<Frame frameStrata="ARTWORK">`, a draw-LAYER name where a strata belongs.
#[test]
fn missing_include_errors_and_continues() {
fn an_unknown_xml_frame_strata_warns_and_leaves_the_stratum_alone() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Frame name="Bad" frameStrata="ARTWORK"/>
<Frame name="After"/>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(
report.errors.is_empty(),
"nothing raised on the XML door: {:?}",
report.errors
);
assert!(
report
.warnings
.iter()
.any(|w| w.contains("Unknown frame strata") && w.contains("ARTWORK")),
"…and it is reported: {report:?}"
);
assert_eq!(
s.eval::<String>("return Bad:GetFrameStrata()").unwrap(),
"MEDIUM",
"the frame keeps what it had — the ctor's MEDIUM ([+0xc0] = 3) in the base case"
);
assert!(
s.eval::<bool>("return After ~= nil").unwrap(),
"and the document carries on"
);
}
/// **The Lua door is the LOUD one and stays that way.** `SetFrameStrata 0x774360`'s miss
/// reaches `0x774456 call 0x6f4940` (`luaL_error`), whose chain `luaG_errormsg 0x6fc780` /
/// `luaD_throw 0x6f5d80` contains no `ret` at all — the epilogue after it is dead code. Pinned
/// beside its XML twin so the asymmetry is a test rather than a comment.
#[test]
fn the_lua_setframestrata_still_raises_on_the_same_value() {
let s = UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "Loud")"#).unwrap();
let err = s
.run(r#"f:SetFrameStrata("ARTWORK")"#)
.unwrap_err()
.to_string();
assert!(err.contains("ARTWORK"), "{err}");
}
/// **`FrameXML_Debug` is a get-or-set whose SET arm turns on the loader's trace lines**
/// (decision 2160) — and `0` takes the SET arm, because the number zero is Lua-truthy and only
/// `nil`/`false` are not (`0x48845d`, after `lua_toboolean 0x6f34d0`). That is the half a
/// re-implementation gets backwards, so it is the half asserted first.
#[test]
fn framexml_debug_is_a_get_or_set_and_gates_the_loader_traces() {
let s = UiScript::new().unwrap();
let doc = || parse(r#"<Ui><Frame name="Traced"/></Ui>"#);
assert_eq!(
s.eval::<i32>("return FrameXML_Debug()").unwrap(),
0,
"boots 0"
);
assert!(
load(&s, &doc(), &no_files).traces.is_empty(),
"and off means no trace at all"
);
assert_eq!(s.eval::<i32>("return FrameXML_Debug(1)").unwrap(), 1);
let on = load(&s, &doc(), &no_files);
assert!(
on.traces
.iter()
.any(|t| t.contains("Creating Frame named Traced")),
"{on:?}"
);
// The two arms that are easy to get wrong.
assert_eq!(
s.eval::<i32>("return FrameXML_Debug(nil)").unwrap(),
1,
"nil is a pure GET — the flag is untouched"
);
assert_eq!(
s.eval::<i32>("return FrameXML_Debug(0)").unwrap(),
0,
"…but 0 is TRUTHY in Lua, so it really does disable it"
);
assert!(load(&s, &doc(), &no_files).traces.is_empty());
// …and the stored value is truncated toward zero, `0x40a2b0`'s conversion.
assert_eq!(s.eval::<i32>("return FrameXML_Debug(1.9)").unwrap(), 1);
}
/// A missing include is **reported, not raised**, and the rest of the load still proceeds.
///
/// It was a warning until 1186 and an error from 1186 to 2155; it is now its own list. Both of
/// the findings behind those moves are asserted here, because each undid the other:
/// **1186's** — a document that resolved *nothing* must not report success (Bagnon missed all
/// eleven of its references and came back with zero errors) — so the row is in the report; and
/// **2155's** — the reference logs `Couldn't open %s` and carries on with nothing raised
/// (wow-re `ui/scratch/xml-toc-path-resolution.md` §4, VERIFIED) — so the row is *not* in
/// `errors`, which is the list whose entries reach the player's red error dialog.
#[test]
fn missing_include_is_a_missing_file_not_an_error_and_continues() {
let s = UiScript::new().unwrap();
let doc = parse(r#"<Ui><Include file="Nope.xml"/><Frame name="Still"/></Ui>"#);
let report = load(&s, &doc, &no_files);
assert!(
report.errors.iter().any(|e| e.contains("Nope.xml")),
"an unresolved include drops a whole document: {:?}",
report.missing_files.iter().any(|e| e.contains("Nope.xml")),
"an unresolved include drops a whole document and says so: {report:?}"
);
assert!(
report.errors.is_empty(),
"…but nothing raised, so it is not a script error: {:?}",
report.errors
);
assert!(
@@ -447,17 +556,38 @@ mod loader_tests {
);
}
/// So is a missing `<Script file=>` — it drops every handler the file would have defined.
/// So is a missing `<Script file=>` — it drops every handler the file would have defined, and
/// the reference's own leg for it (`"Error loading %s"`, `include-lua-dispatch.md` §7) returns
/// normally rather than throwing.
#[test]
fn missing_script_file_errors_and_continues() {
fn missing_script_file_is_a_missing_file_not_an_error_and_continues() {
let s = UiScript::new().unwrap();
let doc = parse(r#"<Ui><Script file="Nope.lua"/><Frame name="Still"/></Ui>"#);
let report = load(&s, &doc, &no_files);
assert!(
report.errors.iter().any(|e| e.contains("Nope.lua")),
"{:?}",
report.errors
report.missing_files.iter().any(|e| e.contains("Nope.lua")),
"{report:?}"
);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(s.eval::<bool>("return Still ~= nil").unwrap());
}
/// **A `<Script file=>` whose chunk RAISES is still an error** — the half `missing_files` must
/// not swallow. The two arms sit one line apart in `load_in`, and folding a miss into the
/// quiet list is only correct because the raise keeps its own.
#[test]
fn a_script_file_that_raises_is_still_an_error() {
let s = UiScript::new().unwrap();
let doc = parse(r#"<Ui><Script file="Boom.lua"/><Frame name="Still"/></Ui>"#);
let files = |req: &str| -> Option<Vec<u8>> {
(req == "Boom.lua").then(|| b"error('boom')".to_vec())
};
let report = load(&s, &doc, &files);
assert!(
report.errors.iter().any(|e| e.contains("Boom.lua")),
"a chunk that raised is an error, not a missing file: {report:?}"
);
assert!(report.missing_files.is_empty(), "{report:?}");
assert!(s.eval::<bool>("return Still ~= nil").unwrap());
}
+9
View File
@@ -1317,6 +1317,15 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripte
.errors
.push(e.to_string());
}
// **A nameplate's click is the engine's too** (decision 2148): the reference's plate overrides
// the button click slot (`0x7cb910`) *and* chains the base, so its unit is selected whether the
// click came from the pointer or from Lua's own `Click()` — this funnel is both. Recorded after
// the handler, like the loot take below and for the same reason: an addon hook that errors
// cannot silently eat the selection. A press (`down`) records nothing — the plate registers for
// `LeftButtonUp | RightButtonUp` only (`RegisterForClicks(0x500)` at `0x7cb637`).
if !down {
super::nameplate::note_click(lua, id, button);
}
// …and then the take, unconditionally on the handler's outcome. `0x4c1867` is not guarded by
// anything the Lua side did: the base call at `0x4c1833` returns void and its result is never
// tested. A row whose `OnClick` errored still loots, which is the reference's behaviour and
+32
View File
@@ -61,6 +61,38 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set("IsMacClient", lua.create_function(|_, ()| Ok(Value::Nil))?)?;
// **`FrameXML_Debug([v])` — the XML loader's own trace switch, get-or-set** (decision 2160,
// wow-re `ui/scratch/framexml-debug-trace-flag.md`). `0x488440` reads the global `[0xceea30]`
// through `0x6edb40`, and:
//
// - a **Lua-truthy** argument takes the SET arm (`0x48845d je` after `lua_toboolean 0x6f34d0`)
// — so `FrameXML_Debug(0)` genuinely disables it rather than being a masked no-op, because
// the NUMBER zero is truthy in Lua; only `nil`/`false` are not;
// - the stored value is `lua_tonumber` truncated **toward zero** (`0x40a2b0`), so `1.9` is 1
// and a non-numeric string is 0, which is 5.0's `tonumber` coercion;
// - an absent, nil or false argument is a pure GET and leaves the flag alone;
// - it always returns ONE number — the flag's value *after* the call
// (`re/audit/binding-shapes.tsv`: `argc 1 exact, returns 1, (number), agree`).
//
// The reference ships a call to it commented out in its own `BasicControls.xml:20`; the
// corpus's consumer is `ImprovedErrorFrame`, which drives it off a saved `XMLDebug` CVar at
// its OnLoad and died on the missing global. What it gates is
// [`crate::loader::LoadReport::traces`].
g.set(
"FrameXML_Debug",
lua.create_function(|lua, v: Value| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let truthy = !matches!(v, Value::Nil | Value::Boolean(false));
if truthy {
// `lua_tonumber`'s coercion, then truncate toward zero. Anything that will not
// coerce is 0 — the same answer `0x6f3620` gives for a non-numeric argument.
let n = lua.coerce_number(v)?.unwrap_or(0.0);
model.framexml_debug.set(n.trunc() as i32);
}
Ok(model.framexml_debug.get())
})?,
)?;
// version, build, date — three, and no fourth (decision 1842)
g.set(
"GetBuildInfo",
+1 -1
View File
@@ -115,7 +115,7 @@ pub use nameplate::{PlateGeometry, PlateState};
mod model;
mod modelframe;
mod net_stats;
mod object;
pub(crate) mod object;
pub use object::frame_kind_from_tag;
mod party;
mod pet;
+12
View File
@@ -125,6 +125,17 @@ pub(crate) struct Model {
/// the same kind of thing — VM-global registries the loader fills.
pub(crate) framexml_templates:
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
/// **`FrameXML_Debug`'s flag** — the loader's own trace-severity switch (decision 2160).
///
/// `0x488440` is a get-or-set over the single global `[0xceea30]`, which boots at 0 and is
/// read at six sites image-wide: the binding itself and five inside the XML loader, each
/// gating a severity-0 trace line behind `flag > 0` (`0x6ee298 jle` — **greater than**, not
/// non-zero, which is why this is signed). The one site walked to the bytes is
/// `Instantiate 0x6ee280`, whose gated line is `0x871154 "-- Creating %s named %s"`.
///
/// Here rather than on `UiScript` for `framexml_templates`' reason: the loader runs from a
/// bare `&Lua`, and so does the binding that writes this.
pub(crate) framexml_debug: std::cell::Cell<i32>,
/// The FrameXML **font-element registry** (a separate namespace — a font inherits a font,
/// never a frame template), persisted for the same cross-file reason.
pub(crate) framexml_fonts:
@@ -1810,6 +1821,7 @@ impl Model {
addons_saved_account: None,
addons_saved_character: None,
framexml_templates: Default::default(),
framexml_debug: Default::default(),
framexml_fonts: Default::default(),
arena: WidgetArena::new(),
layout_inputs: HashMap::new(),
+120 -14
View File
@@ -184,6 +184,13 @@ struct Plate {
last: Option<PlateState>,
}
impl Plate {
/// The unit this plate is currently bound to, or `None` while it sits in the pool.
fn key(&self) -> Option<u64> {
self.last.as_ref().map(|s| s.key)
}
}
/// The plate pool — grown on demand, **never shrunk**, in creation order.
///
/// The reference's pool is unbounded and its free list FIFO; what matters to an addon is only that
@@ -198,10 +205,28 @@ pub(crate) struct NamePlates {
/// `[unit+0xe60]`, from this side. A slot leaves this map only when its unit's plate is
/// retired, and the slot itself never moves.
assigned: std::collections::HashMap<u64, usize>,
/// The reverse of the arena's own structure: which slot a frame handle is. Filled at
/// creation and never removed (plates are never destroyed), so the pointer boundary and the
/// click funnel can ask "is this frame a plate, and whose?" in one lookup.
by_frame: std::collections::HashMap<FrameHandle, usize>,
/// Completed clicks on plates, drained by the app each frame
/// ([`UiScript::take_nameplate_clicks`]).
clicks: Vec<NamePlateClick>,
/// The geometry every live plate is currently laid out under.
geometry: Option<PlateGeometry>,
}
/// A completed click on a plate — what the reference's own `CGNamePlateFrame` click slot
/// (`0x7cb910`) turns into a selection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NamePlateClick {
/// The unit whose plate was clicked ([`PlateState::key`]).
pub key: u64,
/// `"LeftButton"` / `"RightButton"` — the plate registers for both, on mouse-UP only
/// (`RegisterForClicks(0x500)` at `0x7cb637`).
pub button: String,
}
/// What a sync produced that has to be fired **outside** the model borrow: the frames whose
/// effective visibility flipped (their `OnShow`/`OnHide`), and the bars whose value moved (their
/// `OnValueChanged`, which pfUI hooks — `nameplates.lua:393` — and reads `this:GetParent()` in).
@@ -234,6 +259,69 @@ impl UiScript {
}
}
/// **The unit whose plate the pointer is inside**, or `None` — the reference's plate OnEnter
/// (`0x7cb850`) publishing the mouseover global `[0xb4e2c8]`, read from the app's side instead
/// of pushed.
///
/// The app needs this because a plate is real UI here: with the plate mouse-enabled, the UI
/// pointer pass owns the cursor over it and `target::hover`'s world pick correctly stands
/// down — so the mouseover has to come from the frame that took it. Exactly the hovered frame,
/// never an ancestor: an addon frame parented to a plate takes the hover in the reference too,
/// and its OnEnter is the one that fires.
pub fn hovered_nameplate(&self) -> Option<u64> {
let lua = self.lua();
let model = lua.app_data_ref::<Model>().expect("model app_data");
let frame = model.mouseover?;
let slot = *model.nameplates.by_frame.get(&frame)?;
model.nameplates.plates.get(slot)?.key()
}
/// Drain the completed plate clicks — the app turns each into a selection, which is what the
/// reference's own click slot does. Both a physical click and Lua's `plate:Click("LeftButton")`
/// arrive here, because both go through the one click funnel.
pub fn take_nameplate_clicks(&mut self) -> Vec<NamePlateClick> {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
std::mem::take(&mut model.nameplates.clicks)
}
/// **The mouselook toggle** (`0x60f830`): entering camera freelook disables mouse input on
/// every plate, leaving re-enables it — the reference walks its own intrusive plate list at
/// `ds:0xc4d92c` doing exactly this, called from `0x483e80` (enter) and `0x483e70` (leave).
///
/// Without it a right-drag that starts over a plate would be a plate click instead of a camera
/// turn, and the plates would keep taking a pointer that is no longer on screen.
pub fn set_nameplate_mouse(&mut self, enabled: bool) {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frames: Vec<FrameHandle> = model.nameplates.plates.iter().map(|p| p.frame).collect();
for frame in frames {
model.arena.set_mouse_enabled(frame, enabled);
}
}
/// **Retire every live plate** — hide them all and return them to the pool, without touching
/// the geometry they are laid out under.
///
/// The app's every early return owes this: a painter that stopped drawing left no plates
/// behind, but widgets stay until something hides them, so a V press that turns plates off, a
/// frame with no camera, or a world exit would otherwise leave the last frame's plates standing
/// on screen. It is `0x608a10` applied to the whole active list, which is what the reference
/// does when the master toggle clears.
pub fn retire_nameplates(&mut self) {
let lua = self.lua();
let effects = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mut effects = SyncEffects::default();
model.nameplates.assigned.clear();
for i in 0..model.nameplates.plates.len() {
Plate::retire(&mut model, i, &mut effects);
}
effects
};
super::event::fire_visibility_changes(lua, effects.visibility);
}
/// How many plate widgets the pool holds — the plate driver's census, and the tests'.
pub fn nameplate_pool_len(&self) -> usize {
let lua = self.lua();
@@ -242,6 +330,26 @@ impl UiScript {
}
}
/// Record a completed click on `id` if that frame is a live plate — [`super::button::click_button`]
/// calls this for every button click, physical or scripted.
pub(super) fn note_click(lua: &mlua::Lua, id: u32, button: &str) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(frame) = model.id_to_frame.get(&id).copied() else {
return;
};
let Some(&slot) = model.nameplates.by_frame.get(&frame) else {
return;
};
// A pooled (retired) plate has no unit, and clicking one is not possible anyway — it is hidden.
let Some(key) = model.nameplates.plates.get(slot).and_then(Plate::key) else {
return;
};
model.nameplates.clicks.push(NamePlateClick {
key,
button: button.to_string(),
});
}
/// The whole per-frame drive: grow, re-lay-out if the window moved, write what changed, retire the
/// tail.
fn sync(
@@ -321,20 +429,13 @@ impl Plate {
.unwrap_or_default();
model.arena.set_frame_strata(frame, strata);
model.arena.set_frame_level(frame, PLATE_LEVEL, true);
// **The mouse bit stays OFF, and that is a deliberate gap, not an omission** (2148 §8).
// The reference's plate is mouse-enabled — hovering it publishes the mouseover
// (`0x7cb850` → `0x492890`) and a click selects through the button's own click slot. On
// this engine, a mouse-enabled frame under the cursor is what makes `PointerOverUi` true,
// and `target::hover::update_hover` returns early on exactly that: setting the bit without
// an engine-side hover/click seam takes the hover and gives nothing back — no model
// brighten, no lit bar, and no selection, because the click stops at the UI too. The seam
// comes first; then the bit, in the same change.
// `a_plate_does_not_yet_claim_the_ui_pointer` is the tripwire.
//
// It has to be turned OFF explicitly: a `Button` is born mouse-enabled here, faithfully —
// `CSimpleButton`'s ctor writes `[+0xcc] = 0x4` (`0x7786a3`), which is why the plate needs
// no `EnableMouse` of its own in the reference.
model.arena.set_mouse_enabled(frame, false);
// The plate takes the mouse, which it is born with: `CSimpleButton`'s ctor writes
// `[+0xcc] = 0x4` (`0x7786a3`), so the reference's plate needs no `EnableMouse` of its own
// and neither does ours. Hovering it publishes the mouseover (`0x7cb850` → `0x492890`) and
// a completed click selects through the button's click slot (`0x7cb910`); both reach the
// app through [`UiScript::hovered_nameplate`] and [`UiScript::take_nameplate_clicks`].
// `EnableMouse`/`IsMouseEnabled` are in the corpus's own call set, and pfUI's vanilla
// click-through block drives them.
let border = texture(model, frame, DrawLayer::Artwork, BORDER_TEXTURE);
let glow = texture(model, frame, DrawLayer::Highlight, GLOW_TEXTURE);
@@ -374,6 +475,11 @@ impl Plate {
.visibility
.extend(model.arena.set_shown(frame, false));
model
.nameplates
.by_frame
.insert(frame, model.nameplates.plates.len());
Plate {
frame,
border,
+1 -1
View File
@@ -262,7 +262,7 @@ fn enum_token(s: &str) -> String {
/// The reference's strata NAME table (`0x8119f8`) has eight rows, `BACKGROUND`..`TOOLTIP`; stratum
/// 0 (`WORLD`) has no name and no XML or Lua can put a frame there — the WorldFrame's constructor
/// is its only writer (decision 1984, wow-re `worldframe-widget.md` §4).
fn strata_from_str(s: &str) -> Option<Strata> {
pub(crate) fn strata_from_str(s: &str) -> Option<Strata> {
Some(match enum_token(s).as_str() {
"BACKGROUND" => Strata::Background,
"LOW" => Strata::Low,
+88 -25
View File
@@ -456,34 +456,97 @@ fn an_addon_frame_under_the_worldframe_is_not_a_plate() {
);
}
/// **The plate does NOT claim the UI pointer — not yet, and deliberately.**
///
/// The reference's plate is a mouse-enabled `Button`: hovering it publishes the mouseover
/// (`0x7cb850` OnEnter → `0x492890`) and a click selects the unit through the button's own click
/// slot. benilla cannot simply set that bit, because on this engine the UI pointer pass owning a
/// frame is what makes `PointerOverUi` true, and `target::hover::update_hover` returns early on
/// exactly that — so a mouse-enabled plate with no engine-side seam takes the hover and gives
/// nothing back: no model brighten, no lit bar, and no selection on click, because the click never
/// reaches the world path either.
///
/// So the bit stays off until the seam exists (2148 §8), and this test is the tripwire: turning it
/// on without wiring the hover and click through the engine fails here, in the ABI suite, rather
/// than under the director's cursor.
/// **The plate takes the mouse, and hands its unit back.** It is a `Button` and born mouse-enabled
/// (`CSimpleButton`'s ctor writes `[+0xcc] = 0x4`), so hovering it makes it the mouse focus — which
/// on this engine means the UI pointer pass owns the cursor and the world pick stands down. What
/// keeps the mouseover alive is the seam: the widget layer answers WHICH unit's plate the pointer
/// is inside, exactly as the reference's OnEnter (`0x7cb850`) publishes `[0xb4e2c8]`.
#[test]
fn a_plate_does_not_yet_claim_the_ui_pointer() {
fn hovering_a_plate_names_its_unit() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
let wolf = plate("Wolf", 30.0, 40.0);
let key = wolf.key;
drive(&mut s, &[wolf]);
assert_eq!(s.hovered_nameplate(), None, "nothing hovered yet");
// The plate's TOP-CENTRE is (500, 400) and it hangs 32 units below, so this is its middle.
// `mouse_move` speaks the same y-up FrameXML space the anchors do.
//
// The WorldFrame itself IS a legitimate hit here — it is mouse-enabled by construction, and
// the app filters exactly that one id out (`input::feed_ui_input`, decision 1983) so camera
// look, world clicks and hover targeting stay live over it. So the assertion mirrors the
// app's own filter: whatever the pointer lands on must be the WorldFrame, never a plate.
let focus = s.mouse_move(500.0, 384.0);
s.mouse_move(500.0, 384.0);
assert_eq!(s.hovered_nameplate(), Some(key));
// Off the plate: the WorldFrame takes the focus again, and no plate is named.
s.mouse_move(50.0, 50.0);
assert_eq!(s.hovered_nameplate(), None);
}
/// A completed click on a plate reaches the app — the reference's click slot (`0x7cb910`), whose
/// tail is the same `SetSelection` a click on the body runs.
///
/// **Mouse-UP only**: the plate registers `LeftButtonUp | RightButtonUp` (`RegisterForClicks(0x500)`
/// at `0x7cb637`), so a press alone selects nothing.
#[test]
fn a_completed_click_on_a_plate_reaches_the_app() {
let mut s = vm();
let wolf = plate("Wolf", 30.0, 40.0);
let key = wolf.key;
drive(&mut s, &[wolf]);
s.mouse_move(500.0, 384.0);
s.mouse_button(500.0, 384.0, "LeftButton", true);
assert!(
focus.is_none_or(|id| s.is_world_frame(id)),
"a plate claimed the UI pointer: the hover and click seams (2148 §8) have to land in the \
same change that enables the mouse, or plate hover and click-select both die"
s.take_nameplate_clicks().is_empty(),
"a press is not a click: the plate fires on the UP edge only"
);
s.mouse_button(500.0, 384.0, "LeftButton", false);
let clicks = s.take_nameplate_clicks();
assert_eq!(clicks.len(), 1);
assert_eq!(clicks[0].key, key);
assert_eq!(clicks[0].button, "LeftButton");
assert!(
s.take_nameplate_clicks().is_empty(),
"drained, not repeated"
);
}
/// pfUI's click-through calls `plate:Click("LeftButton")` (`nameplates.lua:1274`), and
/// CustomNameplates and `_Nameplates` do the same. A scripted click has to select the unit like a
/// physical one — in the reference both go through the button's one click slot, and here they go
/// through the one funnel that slot's twin lives on.
#[test]
fn a_scripted_click_selects_too() {
let mut s = vm();
let wolf = plate("Wolf", 30.0, 40.0);
let key = wolf.key;
drive(&mut s, &[wolf]);
s.run(r#"WorldFrame:GetChildren():Click("RightButton")"#)
.unwrap();
let clicks = s.take_nameplate_clicks();
assert_eq!(clicks.len(), 1);
assert_eq!(clicks[0].key, key);
assert_eq!(clicks[0].button, "RightButton");
}
/// **The mouselook toggle** (`0x60f830`): entering freelook hands the mouse back on every plate,
/// leaving takes it again — the reference walks its own intrusive plate list doing exactly this,
/// from `0x483e80` (enter) and `0x483e70` (leave). Without it a right-drag that begins over a plate
/// would be a plate click instead of a camera turn.
#[test]
fn freelook_hands_the_mouse_back() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
s.mouse_move(500.0, 384.0);
assert!(s.hovered_nameplate().is_some());
s.set_nameplate_mouse(false);
s.mouse_move(500.0, 384.1);
assert_eq!(
s.hovered_nameplate(),
None,
"a plate must not hold the pointer while the camera does"
);
s.set_nameplate_mouse(true);
s.mouse_move(500.0, 384.0);
assert!(
s.hovered_nameplate().is_some(),
"and it takes it back on leave"
);
}