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