the leader crown updates, the trail darkens, and four dead options work

- the party-leader crown on your target's portrait only ever appeared or vanished when you re-targeted them: `PLAYER_FLAGS_CHANGED` was one of eighteen events the migrated stock windows register that nothing here produced, and the census row naming it said "TargetFrame.lua — the AFK/DND badge", which sent the first look at the wrong window — 1.12 draws no AFK/DND badge on any unit frame and build 5875 has no `UnitIsAFK`/`UnitIsDND` at all (ours are a deliberate invention, 2043; the `<AFK>`/`<DND>` the era shows is the chat line's `arg6`), and the event's sole FrameXML consumer is `TargetFrame.lua:88-95` re-running `UnitIsPartyLeader("target")`, whose descriptor leg is bit `0x1` of this very dword; the handler `0x5ee990` XOR-diffs old against new at `0x5ee9b8` and fires 407 at `0x5eea3d` with no bit test in between and ABOVE the local-player GUID gate at `0x5eea93`, once per unit token naming that GUID through `0x515e50`'s fan-out (`arg1` = the token, no `arg2`, and a zero token count skips the fan-out so a stranger nobody targets signals nothing — free here, since `fire_transitions` only ever runs per token); so the trigger is the RAW dword, `UnitState::player_flags`, the `flags`/`UNIT_FLAGS` pattern one field over, because firing off the two bits the struct decodes would under-announce AFK, DND, GM, PvP-desired, helm, cloak and both play-time bits, and `prev.is_some()` keeps a unit's first snapshot silent as its CREATE runs no notify pass; reading the whole handler found an over-fire that was inherited, not invented: benilla fired `PLAYER_UPDATE_RESTING` on ANY flags delta because wow-re's rested-XP note said in as many words that `0x5eeaf2` fires "on every flags change, not only the resting bit", and the bytes say otherwise — `0x5eead0` `f6 45 fc 20 / 74 26` is `test byte [ebp-4],0x20 ; je 0x5eeafc` and the fire sits inside that arm — so every helm toggle, leadership pass and AFK flip had been announcing a resting change to the whole UI; narrowed to the resting bit's own edge, and the same handler's `0x5eeb65 test ah,0x30` arm becomes `PLAYTIME_CHANGED`, whose consumer `PlayerFrame_UpdatePlaytime` already had all three verbs it reads; the claim is corrected in wow-re by a four-derivation round that confirmed all three arms plus the CREATE question; three tests pin it — the producer with a control on `0x400`, a bit no field decodes; the over-fire regression on the real feed, where a helm bit moving fires neither self event; and the crown itself on the stock `TargetFrame.lua`, leadership passing to a held target lighting it with no re-target and the same event for `arg1 = "player"` not — a fixture that first failed on its opening assertion because `UnitIsPartyLeader`'s second leg is a raw GUID compare with no zero guard, so a default `guid: 0` equalled the zeroed leader of an empty group (verified 1.12: it is why `UnitIsPartyLeader(nil)` answers 1 while solo); the unproduced-events census goes 18 → 16 per window, 31 → 29 whole (2078)
- the weapon swing trail darkens with the scene instead of burning at its authored colour: 2076 shipped it unlit and said why — the draw callback turns lighting ON (EGxRs `0x0e = 1` at `0x6c6847`) over vertex format 7, position + packed colour with no normal, and the state bit alone does not say what fixed-function produces from that; the bytes now do: `0x5a1e30` sets DIFFUSE- and AMBIENTMATERIALSOURCE both to `D3DMCS_COLOR1` off the format's diffuse bit, so the vertex colour IS the material; the normal is explicitly disabled rather than stale (`0x592a60` replaces the format mask instead of OR-ing it, `0x59c100` issues `glDisableClientState(GL_NORMAL_ARRAY)` when the bit is clear, and D3D's format-7 descriptor carries −1 in the normal column), so there is no N·L and the diffuse half contributes nothing; and the global `D3DRS_AMBIENT` (EGxRs id 6) has zero writers image-wide and defaults to 0, an earned zero — leaving `out = (Σ enabled lights' ambient) × authoredColour + inherited emissive`, which is why this does not take the lit emitters' `clamp(ambient + diffuse·max(N·L, 0))` term, a sun the reference does not have; `weapon_trail.rs` multiplies the trail's RGB by `WowLighting.ambient`, folded on the CPU into the vertex colours because a per-draw constant rides the vertex stream on this lane, drawing as `EffectLighting::None` and at the authored colour until the first lighting resolve; two named approximations of the class the fishing line already carries — the scene ambient rather than a per-model light collector, so an interior trail takes the exterior ambient, and no emissive, since `EMISSIVEMATERIALSOURCE` is set nowhere and reproducing it would mean inventing a constant; the other half of 2076's open list was a deviation benilla does not have: it claimed the combat-over-combat fast path (`0x5fe43c` returning at `0x5fe48b`, before the trail latch's only read at `0x5fe48e`) was unmodelled and cost trails starting one play early — both wrong, because `0x60d835` arms a one-slot field with a plain `mov` so a second proc OVERWRITES the first and firing on every request would draw superseded arms with superseded colours on 23 of the 34 type-8 kits, and because the driver's request loop already fast-paths it (0406, `select::is_combat_anim` = `0x5fcc10`'s byte-decoded set) and `continue`s without playing, so the edge never rises; a negative asserted about our own code from having written it rather than looked, shipped to wow-re where it drove a build-it verdict for something already built — now pinned by a test that names both mechanisms; and `benilla-extract charprocs` prints `CharParamOne` beside every trail row: wow-re's note justified "not read by this arm" with "every shipped type-8 row carries 20.0 there", and over the 34 kits the column takes six values (22 at 20, six at 15, two at 25, two at 0, one at 17, one at 22 — Sinister Strike's kit 399 is a 15); nothing here reads it either way, but a census that hides the one column a note asserts about cannot catch the note being wrong, and wow-re's re-measure agreed value for value and found the column live at four sites in `0x60d7c0` for other proc types (2079)
- four options that ticked and did nothing — Show Tutorials, Tip of the Day, Profanity Filter, Disable Spam Filter — get their backing, and two of the reasons they were dead were wrong: none of the four was a row in our window at all; they sat in the transcribed `UIOptionsFrameCheckButtons` declaration table beside thirteen more whose features benilla lacks, and these four are exactly the ones where the feature existed and only the setting was missing; `SHOW_TUTORIALS` is a bare `{ index = 28 }`, which an earlier census read as "an index-only entry with no store at all" — wrong, the store is the three special-case arms `UIOptionsFrame_Load` (l.239), `_Save` (l.318) and `_SetDefaults` (l.645) keep for the key, `TutorialsEnabled()` reading the acknowledged bank and `ResetTutorials()`/`ClearTutorials()` writing it, verbs benilla has had since 1976, so the row was one `func` pair away, and the reference's `~=` guard comes with it because without it a Defaults click on a page where tutorials are already on would `ResetTutorials()` and re-arm every popup the player has dismissed; that census moves out of prose into a test checked both directions, the posture the CVar defaults already hold — a name the reference's table carries that nothing registers and no listed blocker fails, and a listed name that has since been registered fails too, twelve rows listed today with the feature each waits on; the two chat filters are PCRE over shipped DBCs, not word lists: `ChatProfanity.dbc` (2289 rows off `patch.MPQ` — `dbc.MPQ`'s copy is 1512) feeds the masker `0x4a1a60` and `SpamMessages.dbc` (28 gold-seller URL patterns; `dbc.MPQ` has none) feeds the predicate `0x4a1ca0`; Blizzard PATCHED PCRE's escape table (`.rdata 0x812330`) so `\<`/`\>` alias onto `\b`, and an unpatched engine makes them literals and silently kills the whole ASCII half of the profanity list while looking correct; CASELESS folds ASCII only, so pattern and subject are both ASCII-lowercased and matched case-sensitively rather than through `(?i)`, which would match `É` where the reference matches nothing; the masker is in place and length-preserving, one token per byte cycling `!@#$%^&*` from a process-global index never reset for the session, patterns in list order not text order — wow-re's oracle transcript reproduced against the real list, "you are a twat and a faggot" → "you are a &*!@ and a !@#$%^", the later word taking the earlier characters because its row (507) precedes the other's (530); two corrections to what this tree believed: `spamFilter` is armed for every chat type EXCEPT `CHAT_MSG_FILTERED` (`0x49aad2`'s `jne` skips the disarm, which the older note read backwards, so our comments claimed the filter bit only on the one type it is exempt from), and `profanityFilter` gates INSIDE the masker at `0x4a1a66`, covering all thirteen call sites rather than the guild MOTD alone; `CHAT_MSG_FILTERED` (`0x5B`) comes with them — it was on the "never `SMSG_MESSAGECHAT` wire" list and is the server's "your message was filtered" notice `ChatFrame.lua` renders at l.1405, dropped with a warning before and delivered now; the chokepoint runs spam, then mask, then fire, in the reference's order, since a dropped line must not consume mask indices; named rather than faked: the other twelve masker sites (mail, guild, item text, the send path) and their `dl = 1` memo, and the whisper leg's `CMSG_CHAT_FILTERED` report whose body is a wow-re DEFERRED with no vmangos handler; each row sits in 1.12's own seat — Profanity Filter closes the Basic Options Display column, Show Tutorials and Show Loading Screen Tips open the Help box, Disable Spam Filter is the last link of the Advanced chat chain and value-inverted, checked ⇔ `spamFilter == "0"` (2077)
- the loading screen shows the tip of the day: `GameTips.dbc` is 74 records in the 5875 enGB chain, off `patch.MPQ`, ids unsorted (the file opens on 396 and closes on 412), so the index the client persists is a record index in load order, never an id; `gameTip` holds the NEXT index, not the one on screen — the client reads it, shows that row and stores `read + 1`, so on-disk values run 1..=74 and never 0, and the reference's own `Config.wtf` reading `SET gameTip "34"` while showing row 33 is exactly what an off-by-one anchors on; the wrap is a clamp, not a modulo (`if (i < 0 || i >= count) i = 0` at `0x46b682`), the walk is sequential, and both guards — `showGameTips` off and the roster's level-zero flag — jump past the `CVar::Set` as well as the draw, so turning tips off freezes the cursor rather than burning through the table invisibly; it draws only on the glue→world transition, because the setter `0x406630` has exactly one caller inside `EnterWorld` and neither `SMSG_TRANSFER_PENDING` arm reaches it, so the pick hangs off the loading screen's world-entry raise and a portal or worldport screen carries no tip; placement is the reference's: `s = 515/(a·1024)` with `a` = aspect × 0.75 (not W/H, which inflates the wrap width by a third at 16:9, and not the FrameXML-length idiom, which is off by 1.25 at 4:3), the block at `(0.5 − s·0.5, 0.1)`, left-justified, body `0xd7c8c8c8` with an opaque black shadow at `(+0.001, −0.001)`, laid out once per raise between the backdrop and the progress bar; the one term deliberately not applied is `yoff = (1 − a)·0.5`, which wow-re left unreconciled (`0x406a60` already applies the letterbox as a viewport and `yoff` is the same term added again inside it, a possible double count on a display narrower than 4:3) — benilla's loading screen is a 4:3 content box by construction and at 4:3 the reference's own `yoff` is zero, so the tip sits at the reference's layout on the one aspect where the ambiguity cannot bite; the `|cffffd100Tip:|r ` prefix and the trailing CR-LF are in the data and the escapes are interpreted, so the gold "Tip:" is markup and the line is built through the markup tokenizer; the cursor persists through the VM's own CVar table (`write_host_cvar`), since the config file is composed from that table and a host write stopping at the knob would reset it every launch — a live run reads `74 game tips` → `tip 0 of 74` → `tip 1 of 74` with `gameTip = "2"` left in the config, and an earlier live run is what caught a `GameTipSetting` the test harness initialised and the real app did not, a panic no unit test could see (2077)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:11:11 +07:00
co-authored by Claude Fable 5.1
parent cf3189e422
commit ecbdf2f716
26 changed files with 2514 additions and 69 deletions
Generated
+1
View File
@@ -536,6 +536,7 @@ dependencies = [
"objc2-foundation 0.2.2",
"rand 0.8.6",
"raw-window-handle",
"regex",
"rtrb",
"serde",
"smithay-clipboard",
+5
View File
@@ -58,6 +58,11 @@ half = "2"
# macro plus a no-dependency runtime shim — it adds nothing to the graph at runtime, and it is the
# crate everyone uses for this. `assets/ui` is OUR content (MIT/Apache), not Blizzard's.
include_dir = "0.7"
# The 1.12 chat filters are PCRE over shipped DBCs (decision 2077; `text_filter`): 2289 profanity
# expressions and 28 spam ones, compiled once at startup and run over every chat line. `RegexSet` is
# exactly the shape that needs — one automaton over the whole list for the "can anything match at
# all" pass — and the crate is already in the tree transitively.
regex = "1"
# `getrusage` — whole-process CPU time (all threads), the perf probes' `cpu_ms`/`cpu_pct` metric
# (`perf::process_cpu_secs`). Wall-clock fps is unusable as a regression instrument on a machine
# running parallel sessions' builds; CPU-per-frame is, and it is also the number the Mac report
+132 -10
View File
@@ -295,6 +295,31 @@
end
end
-- ── Show Tutorials' SETTER: the reference's own Save arm (decision 2077) ────────────
-- UIOptionsFrame.lua l.318-327, verbatim in behaviour. Its CheckButtons row is a bare
-- `{ index = 28 }` with no cvar and no uvar, which is why decision 1140's census filed it
-- as an "index-only entry with no store at all" and left it out; that reading is wrong.
-- The store is not in the TABLE, it is in the three special-case arms `UIOptionsFrame_Load`
-- (l.239), `_Save` (l.318) and `_SetDefaults` (l.645) keep for this key — the getter is
-- `TutorialsEnabled()` and the setter is the Reset/Clear pair, because "disable tutorials"
-- in 1.12 literally means "mark every bit of the acknowledged bank", not a CVar (1976;
-- wow-re censused all 210 `CVar::Register` sites and there is no tutorial CVar).
--
-- The `~=` guard is the reference's and it matters: `TutorialsEnabled()` returns the NUMBER
-- 1 or nil, so re-ticking an already-on row does not re-`ResetTutorials()` — which would
-- re-arm every tutorial the player has already dismissed.
function OptionsFrame_SetTutorialsEnabled(value)
if ( tonumber(value) ~= TutorialsEnabled() ) then
if ( value == "1" ) then
ResetTutorials()
TutorialFrameCheckButton:SetChecked(1)
else
ClearTutorials()
end
TutorialFrame_HideAllAlerts()
end
end
UIOptionsFrameCheckButtons = { };
------------------- Basic Options -------------------
-- Controls
@@ -927,7 +952,12 @@
-- reference's own on-screen order. Its Basic Options Display box is three COLUMNS read
-- left to right: A gives Status Text (CheckButton2), B gives Show Cloak → Show Helm
-- (31 → 20, sitting under the name toggles that live on our Nameplates page), C gives
-- the 42 → 39 → 66 chain. Help (29) closes the page, as before.
-- the 42 → 39 → 66 chain. The **Help** box closes the page, and since 2077 it is
-- the box's own order rather than its one shippable row: 28 (Show Tutorials), 44
-- (Show Loading Screen Tips, anchored under 28 there) then 29 (Show Detailed
-- Tooltips). 28 was left out by 1140's census as an "index-only entry
-- with no store at all"; that reading missed the three special-case arms
-- `UIOptionsFrame_Load`/`_Save`/`_SetDefaults` keep for the key, which are its store.
-- The target-of-target pair (decision 1576) breaks that left-to-right reading, and
-- it has to: 1.12 keeps it in a box this window has no page for — Advanced Options'
-- "Raid & Party" — so there is no column order to preserve, only a neighbour to pick.
@@ -936,7 +966,8 @@
"RowStatusText", "RowTargetOfTarget", "RowTargetOfTargetMode",
"RowShowCloak", "RowShowHelm",
"RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch",
"RowNewbieTips",
"RowProfanityFilter",
"RowShowTutorials", "RowTipOfTheDay", "RowNewbieTips",
},
-- 1.12's Advanced Options **Chat** box (decision 1589, B246 — "no chat section in
-- options"). The page was called `Social` and held exactly the two chat-bubble
@@ -954,17 +985,17 @@
-- · 51 Auto-join Guild Recruitment Channel — carries **no cvar, uvar or func in the
-- reference either** (UIOptionsFrame.lua l.51 is a bare `{ index = 51 }`), so
-- it is inert there; a working row here would be an invention, not a fix
-- · 68 Disable Spam Filter — benilla filters no chat text. Unlike 51 this is a REAL
-- omission and not an inert row: `spamFilter` is registered "1" at `0x402e8e`
-- with a change callback, and its one reader is the chat chokepoint `0x49a870`,
-- which drops a `CHAT_MSG_FILTERED` line on a word-list hit unless the type is
-- whisper (§5, wow-re `system/ui/scratch/spam-filter-cvar.md`). The row lands
-- with the filter.
-- Row 68, Disable Spam Filter, **shipped by 2077** — and the note it replaces was
-- wrong twice over, both corrected at the bytes by wow-re's `text-filter-law.md`.
-- The filter is not a word list, it is 28 compiled PCRE patterns from
-- `SpamMessages.dbc`; and it does not drop `CHAT_MSG_FILTERED` lines, it is armed for
-- every type EXCEPT that one (`0x49aad2`'s `jne` skips the disarm, which the older
-- note read backwards). It drops a matching line silently, with nothing in its place.
-- 1139's note about the 37 → 46 → 38 gap is closed by this record: 46 is Loot Spam,
-- and it has a knob now.
Chat = {
"RowGuildMemberAlert", "RowRemoveChatDelay", "RowChatBubbles", "RowLootSpam",
"RowPartyChatBubbles",
"RowPartyChatBubbles", "RowSpamFilter",
},
-- 1.12's Advanced Options "Action Bars" group, whole (decision 1500 completing 1136).
-- Its six rows, the four bar toggles then the grid switch then the lock. That is the
@@ -3367,11 +3398,74 @@
"Automatic Quest Tracking", "OPTION_TOOLTIP_AUTO_QUEST_WATCH")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowNewbieTips" inherits="OptionsCheckboxRowTemplate">
<!-- Column C's last link (decision 2077): 1.12 anchors
CheckButton5 TOP to CheckButton66's BOTTOM, so
Profanity Filter closes the 42 -> 39 -> 66 chain the
three rows above are. It reads as a chat setting and
the reference files it under Display; the reference's
seat wins, as everywhere else on this page.
What it actually does is wider than its neighbours
suggest: `profanityFilter` gates INSIDE the shared
masker (`0x4a1a66`), so one tick covers all thirteen
of its call sites. benilla wires the largest — the
fourteen social chat types — and `crate::text_filter`
names the twelve still to come. -->
<Frame name="$parentRowProfanityFilter" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoQuestWatch" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoQuestWatch" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "profanityFilter", "Profanity Filter",
"OPTION_TOOLTIP_PROFANITY_FILTER")</OnLoad></Scripts>
</Frame>
<!-- The HELP box opens here (decision 2077). 1.12 seats
CheckButton28 at its TOPLEFT and hangs 44 (Show
Loading Screen Tips) under it, with 29 (this page's
Show Detailed Tooltips) off to the right of the Reset
Tutorials button — so 28 -> 44 -> 29 is the box's own
reading order flattened into our column, which is the
order below.
The Reset Tutorials BUTTON itself is not here: it is
not a checkbox, and `ResetTutorials()` is exactly what
ticking this row already does. -->
<Frame name="$parentRowShowTutorials" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowProfanityFilter" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowProfanityFilter" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<!-- A `func` row, like Show Cloak / Show Helm: there is
no store, the bank IS the state. Its default is the
reference's own hand-written one
(UIOptionsFrame_SetDefaults l.645,
`OptionsFrame_EnableCheckBox(checkButton, 1, 1)`). -->
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self, TutorialsEnabled,
OptionsFrame_SetTutorialsEnabled, "1",
"Show Tutorials", "OPTION_TOOLTIP_SHOW_TUTORIALS")
</OnLoad></Scripts>
</Frame>
<!-- CheckButton44, anchored under 28 in the reference's own
Help box. Its LABEL is 1.12's own and it is not the
name the tooltip uses: `SHOW_TIPOFTHEDAY_TEXT` reads
"Show Loading Screen Tips", while the description
under `OPTION_TOOLTIP_SHOW_TIPOFTHEDAY` still calls it
the tip of the day. Both are the reference's strings,
so both stand. -->
<Frame name="$parentRowTipOfTheDay" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowShowTutorials" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowShowTutorials" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "showGameTips",
"Show Loading Screen Tips",
"OPTION_TOOLTIP_SHOW_TIPOFTHEDAY")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowNewbieTips" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowTipOfTheDay" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowTipOfTheDay" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "SHOW_NEWBIE_TIPS",
"Show Detailed Tooltips", "OPTION_TOOLTIP_SHOW_NEWBIE_TIPS")
@@ -3481,6 +3575,34 @@
"Show Party Chat Bubbles", "OPTION_TOOLTIP_PARTY_CHAT_BUBBLES")
</OnLoad></Scripts>
</Frame>
<!-- CheckButton 68, the last link in the reference's chat
chain, and the row this page's note has been promising
since 1589 ("the row lands with the filter"). It lands
with decision 2077.
**Value-INVERTED**, like Sticky Targeting: the label
says *Disable* Spam Filter, so the box is checked when
`spamFilter` reads "0". That is the reference's own
pair of arms — `UIOptionsFrame.lua` l.253 checks it on
`GetCVar == "0"` and l.337 flips the value before
writing.
The filter behind it drops a line matching any of
`SpamMessages.dbc`'s 28 gold-seller patterns, silently
and with nothing in its place — every chat type except
CHAT_MSG_FILTERED, never for a GM-tagged line or a GM
viewer. See `crate::text_filter`. -->
<Frame name="$parentRowSpamFilter" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowPartyChatBubbles" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowPartyChatBubbles" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "spamFilter", "Disable Spam Filter",
"OPTION_TOOLTIP_DISABLE_SPAM_FILTER")
self.invert = 1
</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- The Nameplates page (0992 — the page 0952 promised when the
@@ -278,6 +278,15 @@ impl Roster {
self.pending_row().map(|c| c.map)
}
/// The pending pick's level — the loading screen's tip-of-the-day guard (decision 2077). The
/// reference keeps a synthesised flag at `[selChar+0x10a]` that `0x5b42a0` sets iff this byte
/// arrived as `0` in `SMSG_CHAR_ENUM`, and a set flag suppresses the tip. vmangos always sends
/// a real level, so the arm is unreachable against our server; it is honoured because it costs
/// one comparison and a different server is free to send a zero.
pub(crate) fn pending_level(&self) -> Option<u8> {
self.pending_row().map(|c| c.level)
}
/// The picked character's `(map, wow xyz)` — **where the world we are about to load actually
/// is**, known from the roster row a whole server round-trip before `SMSG_LOGIN_VERIFY_WORLD`
/// says so. The streamers aim at this during world entry (decision 0777); without it the only
@@ -4114,3 +4114,67 @@ fn a_gait_change_raises_the_anim_edge_and_a_steady_frame_does_not() {
app.update();
assert!(edge(&app), "and so does a one-shot");
}
/// **The combat fast path must NOT raise the anim edge** (decision 2076, wow-re
/// `format7-lighting-term.md`'s second question).
///
/// `0x5fe43c` returns at `0x5fe48b` — *before* `0x5fe48e`, the weapon-trail latch's only read —
/// when the unit is already playing a combat animation and requests another one. So a pending
/// trail arm survives that play. It matters because `0x60d835` arms a **one-slot** field with a
/// plain `mov`: a second proc overwrites the first before it ever fires, and a client that fired
/// on every request would draw trails from arms the reference superseded — with the superseded
/// colours and durations, on 23 of the 34 type-8 kits.
///
/// benilla does not need to build that gate: the driver's request loop already fast-paths
/// combat-over-combat (decision 0406) and `continue`s without playing, so the edge never rises.
/// This pins the connection between the two, which is otherwise invisible — they live in different
/// modules and neither mentions the other's mechanism.
#[test]
fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() {
let mut app = app();
let unit = jumper(&mut app);
let edge = |app: &App| {
app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.started_anim()
};
let parked = |app: &App| {
app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.deferred
};
app.update(); // settle: Stand
// A combat one-shot — Special1H(57) is in `0x5fcc10`'s set, and the model has it.
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 57,
seq: 1,
});
app.update();
assert!(edge(&app), "the first combat play arms normally");
// A SECOND combat request while the first still runs — AttackUnarmed(16), also in the set.
// The fast path re-times the live clip and parks the request; nothing is armed.
app.world_mut().write_message(SwingMessage {
attacker: unit,
victim: None,
hit_info: 0x2,
victim_state: 1,
damage: 21,
seq: 2,
});
app.update();
assert_eq!(
parked(&app),
Some(16),
"the swing parks behind the kit clip — the fast path fired"
);
assert!(
!edge(&app),
"…and it re-times rather than plays, so `0x5fe2f0` returns before the latch read and a \
pending trail arm survives"
);
}
+69 -1
View File
@@ -539,6 +539,32 @@ pub(crate) const REGISTERED: &[Registered] = &[
// the director's `/p` ask, and is a click away on the Chat page.
same("ChatBubbles", "1"),
same("ChatBubblesParty", "0"),
// **The two text filters** (2077) — 1.12's own pair, and both are real features rather than
// vestigial switches, which is what the wow-re §5 round behind `text-filter-law.md` settled.
// Registered `"1"` each, byte-read: `0x402e68` (`profanityFilter`, name `0x82e7f4`, callback
// `0x403570`) and `0x402e8e` (`spamFilter`, name `0x82e7d4`, callback `0x4035b0`), both pushing
// the shared `"1"` literal `0x82e748`, both category 4.
//
// `profanityFilter` masks matched spans of `ChatProfanity.dbc` in place, and it gates INSIDE
// the shared masker (`0x4a1a66`), so all thirteen of its call sites are covered by the one
// switch — the 14 social chat types, mail, the guild MOTD/info/rank names, item text and the
// send path. `spamFilter` is a predicate over `SpamMessages.dbc` at the chat chokepoint that
// **drops** a matching line silently. The knob for both is
// [`crate::text_filter::TextFilterSwitches`]; the engine is `crate::text_filter`.
same("profanityFilter", "1"),
same("spamFilter", "1"),
// **The loading-screen tip of the day** (2077) — 1.12's own pair, both registered lazily by
// `CGlueMgr::EnterWorld` on its way to the config flush (`0x46b633` `gameTip` `"0"`,
// `0x46b658` `showGameTips` `"1"`, both category 5, neither with a callback or a help string;
// wow-re `system/loadingscreen/scratch/game-tip-of-the-day.md`).
//
// `gameTip` is not a preference — it is the **cursor**, and it holds the NEXT row rather than
// the one on screen, which is why the reference's own `Config.wtf` reads `SET gameTip "34"`
// while showing row 33. It is registered here because that is how it persists: the file is
// composed from the VM's live table, so the host's advance writes through it. `crate::game_tip`
// is the law.
same("gameTip", "0"),
same("showGameTips", "1"),
// *Detailed Loot Information* (1589, the Chat page) — 1.12's `showLootSpam`, whose subject is
// group LOOT ROLLS (its own tooltip: "Uncheck this to hide individual loot roll messages and
// only show the winner"). Registered `"1"`, **byte-read**: wow-re's census of `0xb4e2bc`
@@ -898,6 +924,8 @@ pub(crate) struct KnobParams<'w> {
tex_filter: ResMut<'w, benilla_assets::TexFilterSetting>,
pane_rate: ResMut<'w, PaneRate>,
guild_notify: ResMut<'w, crate::ui_guild::GuildMemberNotify>,
text_filter: ResMut<'w, crate::text_filter::TextFilterSwitches>,
game_tip: ResMut<'w, crate::game_tip::GameTipSetting>,
block_trades: ResMut<'w, crate::ui_trade::BlockTrades>,
auto_self_cast: ResMut<'w, crate::ui_action::AutoSelfCast>,
realmlist: ResMut<'w, crate::realmlist::Realmlist>,
@@ -934,6 +962,8 @@ impl KnobParams<'_> {
tex_filter: &mut self.tex_filter,
pane_rate: &mut self.pane_rate,
guild_notify: &mut self.guild_notify,
text_filter: &mut self.text_filter,
game_tip: &mut self.game_tip,
block_trades: &mut self.block_trades,
auto_self_cast: &mut self.auto_self_cast,
realmlist: &mut self.realmlist,
@@ -966,6 +996,8 @@ struct Knobs<'a> {
tex_filter: &'a mut benilla_assets::TexFilterSetting,
pane_rate: &'a mut PaneRate,
guild_notify: &'a mut crate::ui_guild::GuildMemberNotify,
text_filter: &'a mut crate::text_filter::TextFilterSwitches,
game_tip: &'a mut crate::game_tip::GameTipSetting,
block_trades: &'a mut crate::ui_trade::BlockTrades,
auto_self_cast: &'a mut crate::ui_action::AutoSelfCast,
realmlist: &'a mut crate::realmlist::Realmlist,
@@ -1080,6 +1112,12 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// the caller dirties the config and the value persists — with nothing to apply this side.
"statusbartext" | "ubertooltips" => {}
// The two bubble switches (1139) — flags, like every other pair here.
"showgametips" => knobs.game_tip.show = v != 0.0,
// The cursor, not a preference — a hand-edited or downgraded value lands here verbatim and
// `game_tip::raise` clamps it, which is the reference's own tolerance (`0x46b682`).
"gametip" => knobs.game_tip.next = v as i64,
"profanityfilter" => knobs.text_filter.profanity = v != 0.0,
"spamfilter" => knobs.text_filter.spam = v != 0.0,
"chatbubbles" => knobs.bubbles.all = v != 0.0,
"chatbubblesparty" => knobs.bubbles.party = v != 0.0,
// The loot-roll detail switch (1589) — a flag over the roll-line composer's two shapes.
@@ -1376,6 +1414,8 @@ fn sync_cvars(
guild_notify,
block_trades,
auto_self_cast,
text_filter,
game_tip,
msaa,
msaa_formats,
tex_filter,
@@ -1413,7 +1453,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 45] = [
let session: [(&str, String); 49] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1456,6 +1496,10 @@ fn sync_cvars(
("WorldDetail", (clutter.density - 1.0).to_string()),
("ChatBubbles", flag(bubbles.all)),
("ChatBubblesParty", flag(bubbles.party)),
("profanityFilter", flag(text_filter.profanity)),
("spamFilter", flag(text_filter.spam)),
("showGameTips", flag(game_tip.show)),
("gameTip", game_tip.next.to_string()),
("minimapZoom", minimap.outdoor.to_string()),
("minimapInsideZoom", minimap.inside.to_string()),
("gxVSync", flag(video.vsync)),
@@ -1502,6 +1546,24 @@ fn sync_cvars(
}
}
/// **A HOST-side CVar write that persists** — the counterpart to a Lua `SetCVar`, for the one CVar
/// the engine itself owns: `gameTip`, the loading screen's cursor (2077).
///
/// It has to go through the VM's table rather than through the knob alone, because
/// [`save_config`] composes the file from `cvars_snapshot()` — the knob is only ever *seeded* into
/// a VM at claim time, so a host write that stops at the knob is invisible to the file and the
/// cursor resets every launch. Marking dirty here is what arms the debounced save.
pub(crate) fn write_host_cvar(
script: &mut UiScript,
persist: &mut CvarPersist,
name: &str,
value: &str,
) {
script.set_cvar_host(name, value);
persist.dirty = true;
persist.last_change = Some(Instant::now());
}
/// Fold the dying VM's CVar table into the persist state — the session edge's half of decision
/// 1291's bridge (the seed in [`sync_cvars`] is the other). Called from
/// [`crate::ui_script::end_ui_session`] **after** the shutdown events (an addon's
@@ -1923,9 +1985,13 @@ mod tests {
crate::realmlist::Realmlist::unpinned(crate::realmlist::DEFAULT_REALMLIST);
let mut auto_self_cast = crate::ui_action::AutoSelfCast::default();
let mut fps_journal = crate::perf::FpsJournalSetting::default();
let mut text_filter = crate::text_filter::TextFilterSwitches::default();
let mut game_tip = crate::game_tip::GameTipSetting::default();
let mut knobs = Knobs {
sound: &mut sound,
auto_self_cast: &mut auto_self_cast,
text_filter: &mut text_filter,
game_tip: &mut game_tip,
scale: &mut scale,
view: &mut view,
look: &mut look,
@@ -2233,6 +2299,8 @@ mod tests {
.init_resource::<crate::ui_trade::BlockTrades>()
.init_resource::<crate::ui_action::AutoSelfCast>()
.init_resource::<crate::perf::FpsJournalSetting>()
.init_resource::<crate::text_filter::TextFilterSwitches>()
.init_resource::<crate::game_tip::GameTipSetting>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app
+487
View File
@@ -0,0 +1,487 @@
//! The loading screen's **tip of the day** (decision 2077; wow-re
//! `system/loadingscreen/scratch/game-tip-of-the-day.md`).
//!
//! `showGameTips`' only mention in 1.12's FrameXML is its options row and the tooltip string
//! "Uncheck this to hide the tip of the day in the load screens", so the whole feature is
//! engine-side: `CGlueMgr::EnterWorld` picks a row from `GameTips.dbc`, hands it to the loading
//! screen's text slot, and writes the *next* index back into the `gameTip` CVar.
//!
//! ## The selection law (`0x46b662`–`0x46b6e3`)
//!
//! **`gameTip` holds the NEXT index, not the one on screen.** The client reads it, shows *that*
//! row, then stores `read + 1`, so on-disk values run 1..=74 and never 0 — a client that stores the
//! index it just showed is off by one, and the reference's own `Config.wtf` (`SET gameTip "34"`) is
//! what a naive reading mis-anchors on.
//!
//! **The wrap is a clamp, not a modulo**: `if (i < 0 || i >= count) i = 0` at `0x46b682`–`0x46b68f`.
//! `count <= 0` bails. The walk is **sequential**, never random.
//!
//! Two guards sit in front of it. `showGameTips` off jumps past the `CVar::Set` as well as the
//! draw (`0x46b671`/`0x46b678`), so **turning tips off freezes the index** rather than advancing it
//! invisibly. And `[selChar+0x10a]` — a *client-synthesised* flag, set by `0x5b42a0` iff the
//! `SMSG_CHAR_ENUM` level byte arrived as `0` — suppresses the tip for a character the roster
//! reported at level zero. vmangos always sends a real level, so that arm is unreachable against
//! our server; it is honoured anyway because it costs one comparison.
//!
//! ## Where it draws
//!
//! **Only on the glue→world transition.** The setter `0x406630` (a seven-byte
//! `mov [0x882e10],ecx; ret`) has exactly one caller, and neither `SMSG_TRANSFER_PENDING` arm sets
//! it — so an in-world portal or worldport screen carries **no tip**. That is why this module hangs
//! off the loading screen's `world entry` raise and clears on every other one.
//!
//! The reference lays the text out **once per raise** and draws it every frame, between the
//! background quad and the progress bar. Its placement, in the screen's own `[0,1]` ortho:
//! `s = 515 / (a · 1024)` where `a = [0x832a4c]` is **aspect × 0.75** (not the aspect — the writer
//! `0x41ad10` multiplies by `0.75` first, and reading it as W/H inflates the wrap width by a third
//! at 16:9); position `(0.5 − s·0.5, 0.1 + yoff, 0)`; `justifyH = 0` (left); wrap width `s`; body
//! `0xd7c8c8c8` ARGB with an opaque black shadow at `(+0.001, −0.001)`.
//!
//! **`yoff` is `(1 − a)·0.5` and we do not apply it, on purpose.** wow-re left that term
//! *unreconciled*: `0x406a60` already applies the letterbox as a viewport, and `yoff` is
//! bit-for-bit the same `(1 − a)·0.5` added again inside it, which may double-count on a display
//! narrower than 4:3. benilla's loading screen is a **4:3 content box by construction** (the root
//! letterboxes and the area is `100vh × 4/3`), and at 4:3 the reference's own `yoff` is exactly
//! zero — so placing the tip in that box at `y = 0.1` is the reference's layout on the one aspect
//! where the ambiguity cannot bite. If wow-re settles the term differently the fix is this comment
//! and one constant.
//!
//! The `|cffffd100Tip:|r ` prefix and the trailing `\r\n` are **in the DBC data**, and the escapes
//! *are* interpreted (`0x5c28af`, flags bit `0x800` clear), so the gold "Tip:" is markup rather
//! than literal text — which is why the line is built through [`benilla_ui::markup`] rather than
//! drawn as one string.
use benilla_ui::markup::{self, TokenKind};
use bevy::prelude::*;
use benilla_formats::GameTipsCatalog;
/// `s = 515 / (a · 1024)` at `a = 1` — the 4:3 case, which is the aspect of the content box this
/// draws into. It is the text's **scale**, and it doubles as the wrap width in the same `[0,1]`
/// space (`0x406e18` passes `s` for both).
///
/// **Not a FrameXML length.** The reference's idiom here is the FrameXML-unit conversion with one
/// extra instruction (`0x41ae40` divides `G44` back out), so reusing that helper is off by 1.25 at
/// 4:3.
const TIP_SCALE: f32 = 515.0 / 1024.0;
/// `pos.y` — the tip's baseline in the screen's `[0,1]` ortho, measured from the bottom, sitting
/// just above the progress bar's `cy = 0.075`.
const TIP_BOTTOM: f32 = 0.1;
/// `0.018 · [0x832a48]` — the font height as a fraction of the viewport height.
const TIP_FONT_FRACTION: f32 = 0.018;
/// The body colour `0xd7c8c8c8` (ARGB) and its shadow offset, in the same `[0,1]` space.
const TIP_COLOR: Color = Color::srgba(200.0 / 255.0, 200.0 / 255.0, 200.0 / 255.0, 215.0 / 255.0);
const TIP_SHADOW_OFFSET: f32 = 0.001;
/// The two CVars, mirrored: `showGameTips` and the `gameTip` cursor.
///
/// `next` is an `i64` rather than a `u32` because the CVar is a string a player (or a downgraded
/// build, or a bigger locale table) can leave anything in, and the reference's own tolerance is a
/// clamp at read time (`0x46b682`'s `i < 0 || i >= count`), not a validation at write time.
#[derive(Resource, Debug, Clone, Copy)]
pub(crate) struct GameTipSetting {
pub(crate) show: bool,
pub(crate) next: i64,
}
impl Default for GameTipSetting {
fn default() -> Self {
GameTipSetting {
show: true,
next: 0,
}
}
}
/// The tips table plus the one piece of state the reference keeps beside it: which row the screen
/// currently shows, laid out once per raise.
#[derive(Resource, Default)]
pub(crate) struct GameTips {
/// `GameTips.dbc` in file order — the array `[0xc0dcd0]`/`[0xc0dcd4]`.
catalog: GameTipsCatalog,
/// `[0x882e10]` — the row the current screen shows, or `None` for a screen with no tip (tips
/// off, an in-world transfer, an empty table).
shown: Option<String>,
}
impl GameTips {
/// `EnterWorld`'s tip block: read the stored index, clamp it, take that row, and return the
/// index to store back (`shown + 1`).
///
/// `None` when the table is empty (`count <= 0` bails at `0x46b684`).
fn take(&self, stored: i64) -> Option<(&str, u32)> {
let count = self.catalog.len();
if count == 0 {
return None;
}
// The clamp IS the wrap — there is no modulo, and the second range test at
// `0x46b695`–`0x46b69b` is dead.
let index = if stored < 0 || stored >= count as i64 {
0
} else {
stored as usize
};
let tip = self.catalog.get(index)?;
Some((tip, index as u32 + 1))
}
/// The line the screen is showing, if any.
pub(crate) fn shown(&self) -> Option<&str> {
self.shown.as_deref()
}
}
/// The tip split into coloured runs, ready for a Bevy text tree — `|cAARRGGBB…|r` honoured, the
/// trailing `\r\n` the data carries dropped rather than drawn as blank lines.
///
/// Returns `(text, colour)` pairs against `base`, which is the string's own colour that `|r`
/// restores (`0x5cce99` restores `FontString+0x2c`).
pub(crate) fn spans(tip: &str, base: Color) -> Vec<(String, Color)> {
let mut out: Vec<(String, Color)> = Vec::new();
let mut colour = base;
let mut at = 0;
let mut run = String::new();
let flush = |run: &mut String, colour: Color, out: &mut Vec<(String, Color)>| {
if !run.is_empty() {
out.push((std::mem::take(run), colour));
}
};
while let Some(token) = markup::token_at(tip, at) {
at += token.byte_len;
match token.kind {
TokenKind::Color(rgba) => {
flush(&mut run, colour, &mut out);
colour = Color::srgb_u8(rgba.r(), rgba.g(), rgba.b());
}
TokenKind::ColorReset => {
flush(&mut run, colour, &mut out);
colour = base;
}
TokenKind::LineBreak => run.push('\n'),
TokenKind::EscapedPipe => run.push('|'),
TokenKind::Char(c) => run.push(c),
// No shipped tip carries a hyperlink; a locale archive that adds one draws its visible
// text and loses only the click, which nothing on a loading screen could use anyway.
TokenKind::LinkOpen { .. } | TokenKind::LinkClose => {}
}
}
flush(&mut run, colour, &mut out);
// The data's trailing `\r\n` (one tip carries two) would otherwise draw as empty lines under
// the sentence and push the block off its anchor.
if let Some((last, _)) = out.last_mut() {
while last.ends_with('\n') {
last.pop();
}
}
out.retain(|(t, _)| !t.is_empty());
out
}
/// The block's geometry as **percentages of the 4:3 content box** — `(left, bottom, width)`.
/// Percent rather than pixels because the node is laid out once per raise, and a window resize
/// mid-load must not strand it.
pub(crate) const fn geometry() -> (f32, f32, f32) {
(
(0.5 - TIP_SCALE * 0.5) * 100.0,
TIP_BOTTOM * 100.0,
TIP_SCALE * 100.0,
)
}
/// The two numbers that can only be pixels — the font height and the shadow offset — for a content
/// box of `width × height` logical pixels.
pub(crate) fn layout(width: f32, height: f32) -> TipLayout {
TipLayout {
font_size: height * TIP_FONT_FRACTION,
shadow: Vec2::new(width * TIP_SHADOW_OFFSET, height * TIP_SHADOW_OFFSET),
}
}
/// What [`layout`] resolves to, in logical pixels inside the loading screen's 4:3 content box.
pub(crate) struct TipLayout {
pub(crate) font_size: f32,
/// `(+x, −y)` in the reference; Bevy's shadow offset is `(+x, +y)` with y growing DOWN, so the
/// sign is already right.
pub(crate) shadow: Vec2,
}
/// The body colour the tip's own `|r` restores to.
pub(crate) const fn base_color() -> Color {
TIP_COLOR
}
pub(crate) struct GameTipPlugin;
impl Plugin for GameTipPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GameTips>()
// The CVar mirror, init'd HERE and not by the CVar host: `KnobParams` takes it as a
// plain `ResMut`, so a build that registers the row without the resource panics the
// first `load_config` — which is what a live run caught and no unit test could, the
// test harness having its own `init_resource` chain.
.init_resource::<GameTipSetting>()
.add_systems(
Startup,
load_game_tips.after(benilla_assets::AssetSet::Open),
)
// After the loading screen's own drive, which is what sets the edge this reads.
.add_systems(
Update,
drive_game_tip.after(benilla_world::schedule::WorldStage::Present),
);
}
}
/// Take the raise's tip edge, then paint whatever the screen is showing.
///
/// Two jobs in one system because they are one mechanism seen at two moments: the reference picks
/// the row inside `EnterWorld` and lays it out once per raise, then draws that layout every frame.
#[allow(clippy::too_many_arguments)]
fn drive_game_tip(
mut screen: ResMut<crate::loading_screen::LoadingScreen>,
mut tips: ResMut<GameTips>,
mut setting: ResMut<GameTipSetting>,
roster: Option<Res<crate::char_select::Roster>>,
mut node: Query<
(
Entity,
&mut Node,
&mut TextFont,
&mut TextShadow,
&mut Visibility,
),
With<crate::loading_screen::LoadingTip>,
>,
windows: Query<&Window>,
assets: Res<AssetServer>,
mut commands: Commands,
// The cursor persists through the VM's table, not the knob — see `cvars::write_host_cvar`.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut persist: ResMut<crate::cvars::CvarPersist>,
) {
if let Some(edge) = screen.take_tip_edge() {
let next = match edge {
crate::loading_screen::TipEdge::Pick => raise(
&mut tips,
setting.show,
roster
.as_ref()
.is_some_and(|r| r.pending_level() == Some(0)),
setting.next,
),
crate::loading_screen::TipEdge::Clear => {
clear(&mut tips);
None
}
};
if let Some(next) = next {
// The cursor moves only when a tip was actually shown — a suppressed tip freezes it.
setting.next = i64::from(next);
if let Some(mut script) = script {
crate::cvars::write_host_cvar(
&mut script,
&mut persist,
"gameTip",
&next.to_string(),
);
}
// The run's own evidence: which row this screen carries and where the cursor lands.
// A tip is a picture on a screen that is up for a second, so "did it pick one" is a
// question for a log line, not for a capture.
info!("loading screen: tip {} of {}", next - 1, tips.catalog.len());
}
// The text is laid out once per raise, not per frame, exactly as the reference does.
let Ok((entity, mut n, mut font, mut shadow, mut vis)) = node.single_mut() else {
return;
};
let Some(tip) = tips.shown() else {
*vis = Visibility::Hidden;
commands.entity(entity).despawn_related::<Children>();
return;
};
// The 4:3 content box is `100vh` tall and `100vh · 4/3` wide, so the window's height is the
// box's height and the box's width follows from it.
let height = windows.iter().next().map_or(768.0, |w| w.height());
let l = layout(height * 4.0 / 3.0, height);
let (left, bottom, width) = geometry();
n.left = Val::Percent(left);
n.bottom = Val::Percent(bottom);
n.width = Val::Percent(width);
font.font = crate::char_select::wow_font(&assets);
font.font_size = l.font_size;
shadow.offset = l.shadow;
shadow.color = Color::BLACK;
*vis = Visibility::Inherited;
// The coloured runs: the first is the `Text` root's own, the rest are `TextSpan` children.
let runs = spans(tip, base_color());
let mut e = commands.entity(entity);
e.despawn_related::<Children>();
match runs.split_first() {
Some(((head, head_color), rest)) => {
e.insert((Text::new(head.clone()), TextColor(*head_color)));
let (rest, tf) = (rest.to_vec(), font.clone());
e.with_children(|c| {
for (text, color) in rest {
c.spawn((TextSpan::new(text), tf.clone(), TextColor(color)));
}
});
}
None => {
e.insert(Text::new(String::new()));
*vis = Visibility::Hidden;
}
}
}
}
/// `GameTips.dbc`, once, off the patch chain — the reference's own single linear load, with no
/// reload path. `.after(AssetSet::Open)` for the reason every DBC load in this tree carries it.
fn load_game_tips(mut tips: ResMut<GameTips>, assets: Option<Res<benilla_assets::WorldAssets>>) {
let Some(assets) = assets else { return };
use benilla_assets::LockRecover;
let mut chain = assets.chain.lock_recover();
match benilla_formats::load_game_tips(&mut chain) {
Ok(cat) => {
info!("loading screen: {} game tips", cat.len());
tips.catalog = cat;
}
// A missing table is a loading screen with no tip, not a boot failure.
Err(e) => warn!("GameTips.dbc unavailable — loading screens carry no tip: {e:#}"),
}
}
/// Pick the row for a screen that is about to rise, and hand back the index to store.
///
/// `show` is `showGameTips`; `level_is_zero` is `[selChar+0x10a]`. Both suppress the tip AND the
/// advance — the reference jumps past its own `CVar::Set`, so an off switch freezes the index.
pub(crate) fn raise(
tips: &mut GameTips,
show: bool,
level_is_zero: bool,
stored: i64,
) -> Option<u32> {
if !show || level_is_zero {
tips.shown = None;
return None;
}
match tips.take(stored) {
Some((tip, next)) => {
tips.shown = Some(tip.to_string());
Some(next)
}
None => {
tips.shown = None;
None
}
}
}
/// Every other raise — an in-world portal or worldport — carries no tip.
pub(crate) fn clear(tips: &mut GameTips) {
tips.shown = None;
}
#[cfg(test)]
mod tests {
use super::*;
fn tips(rows: &[&str]) -> GameTips {
GameTips {
catalog: GameTipsCatalog::from_tips(rows.iter().map(|s| (*s).to_string()).collect()),
shown: None,
}
}
/// **`gameTip` holds the NEXT index.** A fresh client's registered `"0"` shows row 0 and stores
/// 1, so on-disk values run 1..=count and never 0 — the off-by-one a client that stores what it
/// just showed would ship.
#[test]
fn the_stored_index_is_the_next_one_not_the_shown_one() {
let mut t = tips(&["a", "b", "c"]);
assert_eq!(raise(&mut t, true, false, 0), Some(1));
assert_eq!(t.shown(), Some("a"));
assert_eq!(raise(&mut t, true, false, 1), Some(2));
assert_eq!(t.shown(), Some("b"));
}
/// The wrap is the **clamp**, not a modulo: anything at or past the count restarts at 0, and so
/// does a negative. A stored index from a bigger table (a locale archive, a patch) lands on
/// row 0 rather than nothing.
#[test]
fn the_clamp_is_the_wrap() {
let mut t = tips(&["a", "b", "c"]);
assert_eq!(raise(&mut t, true, false, 3), Some(1), "count wraps to 0");
assert_eq!(t.shown(), Some("a"));
assert_eq!(
raise(&mut t, true, false, 900),
Some(1),
"far past, same clamp"
);
assert_eq!(raise(&mut t, true, false, -4), Some(1), "and negative");
}
/// Both guards suppress the tip **and the advance** — the reference jumps past its own
/// `CVar::Set`, so turning tips off freezes the index rather than burning through the table
/// invisibly.
#[test]
fn a_suppressed_tip_freezes_the_index() {
let mut t = tips(&["a", "b", "c"]);
assert_eq!(raise(&mut t, false, false, 1), None, "showGameTips off");
assert_eq!(t.shown(), None);
assert_eq!(raise(&mut t, true, true, 1), None, "the level-zero guard");
assert_eq!(t.shown(), None);
// …and the index it would have advanced is untouched: the next armed raise still reads 1.
assert_eq!(raise(&mut t, true, false, 1), Some(2));
assert_eq!(t.shown(), Some("b"));
}
/// An empty table bails (`count <= 0` at `0x46b684`) rather than showing a blank line.
#[test]
fn an_empty_table_shows_nothing() {
let mut t = tips(&[]);
assert_eq!(raise(&mut t, true, false, 0), None);
assert_eq!(t.shown(), None);
}
/// The `|c…|r` prefix is **markup in the data**, so the gold "Tip:" is its own run and the rest
/// falls back to the body colour — and the trailing `\r\n` the DBC carries is dropped rather
/// than drawn as blank lines under the sentence.
#[test]
fn the_gold_tip_prefix_is_markup_and_the_trailing_newlines_go() {
let base = base_color();
let runs = spans("|cffffd100Tip:|r Nearby questgivers.\r\n", base);
assert_eq!(runs.len(), 2, "two runs: {runs:?}");
assert_eq!(runs[0].0, "Tip:");
assert_eq!(runs[0].1, Color::srgb_u8(0xff, 0xd1, 0x00));
assert_eq!(runs[1].0, " Nearby questgivers.");
assert_eq!(runs[1].1, base);
}
/// The placement, at the one aspect the reference's own `yoff` is zero — a 4:3 content box,
/// which is what benilla's loading screen always is. `s = 515/1024`, left-anchored at
/// `0.5 − s/2`, wrap width `s`, baseline at `0.1` from the bottom.
#[test]
fn the_layout_is_the_reference_numbers_at_four_three() {
let (left, bottom, width) = geometry();
// `s = 515/1024` — a 515 px column in a 1024-wide box, centred, and the baseline a tenth of
// the box's height up, just clear of the progress bar's `cy = 0.075`.
assert!(
(width * 0.01 * 1024.0 - 515.0).abs() < 0.01,
"s·width = 515 px"
);
assert!(
(left * 0.01 * 1024.0 - (1024.0 - 515.0) / 2.0).abs() < 0.01,
"centred column"
);
assert!((bottom - 10.0).abs() < 0.001, "0.1 of the box height");
let l = layout(1024.0, 768.0);
assert!(
(l.font_size - 13.824).abs() < 0.01,
"0.018 of the box height"
);
}
}
+4
View File
@@ -107,6 +107,7 @@ mod screen_fade;
mod screenshot;
mod shaders;
mod game_tip;
mod name_persist;
/// Where "the client is going down" may be observed, and why that is `Last` and not `Update`
/// (decision 1528). Every system that persists state on the way out registers through it.
@@ -116,6 +117,7 @@ mod sound;
/// The melee swing refusal's latch + 4 s repeat (`SMSG_ATTACKSWING_*`).
mod swing_refusal;
mod target;
mod text_filter;
mod textinput;
mod transport;
mod tutorial;
@@ -668,6 +670,8 @@ pub fn run(build: BuildId) -> AppExit {
.add_plugins(BattlefieldScorePlugin)
.add_plugins(BattlefieldPlugin)
.add_plugins(BattlefieldPositionsPlugin)
.add_plugins(crate::game_tip::GameTipPlugin)
.add_plugins(crate::text_filter::TextFilterPlugin)
.add_plugins(TutorialPlugin)
// The melee swing refusals (`SMSG_ATTACKSWING_NOTINRANGE`/`_BADFACING`/`_DEADTARGET`/
// `_CANT_ATTACK`): the latch the packets set, and the 4 s repeat that shows it while an
+58
View File
@@ -177,6 +177,15 @@ fn count_entry_cover(
#[derive(Resource)]
struct LoadingScreenCatalogRes(LoadingScreenCatalog);
/// What a raise means for the tip of the day (decision 2077).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum TipEdge {
/// The glue→world entry: pick the next row and advance the cursor.
Pick,
/// Every other raise: this screen carries no tip.
Clear,
}
/// Loading-screen state machine (decision 0737: event-raised, readiness-cleared).
#[derive(Resource, Default)]
pub(crate) struct LoadingScreen {
@@ -207,6 +216,11 @@ pub(crate) struct LoadingScreen {
displayed: f32,
/// Decoded backdrop art by BLP path, so repeated teleports to a continent don't re-decode.
art_cache: HashMap<String, Handle<Image>>,
/// **Which tip the next raise should carry** — `Pick` on the glue→world entry, `Clear` on every
/// other raise, taken by [`crate::game_tip::drive_game_tip`] on the same frame. A field rather
/// than a message because it is a property OF the raise, and the raise is this struct's.
pub(crate) tip_edge: Option<TipEdge>,
/// `Time::elapsed_secs` at the last raise + at the last wait-instrument line (see
/// [`WAIT_LOG_AFTER`]).
active_since: f32,
@@ -234,6 +248,12 @@ impl LoadingScreen {
}
}
/// Take the raise's tip edge, if one is pending — read once, by
/// [`crate::game_tip::drive_game_tip`].
pub(crate) fn take_tip_edge(&mut self) -> Option<TipEdge> {
self.tip_edge.take()
}
/// Raise (or re-arm) the screen for a fresh load. `awaiting_snap` marks a raise whose
/// destination snap is still in flight; `map` is the destination when the edge knows it.
fn raise(&mut self, reason: &str, awaiting_snap: bool, map: Option<u32>, now: f32) {
@@ -258,6 +278,10 @@ struct LoadingBackdrop;
struct LoadingBarFill;
#[derive(Component)]
struct LoadingBarBorder;
/// The tip-of-the-day text block (decision 2077) — its `Text` root; the coloured runs are its
/// children, rebuilt when the shown tip changes.
#[derive(Component)]
pub(crate) struct LoadingTip;
pub(crate) struct LoadingScreenPlugin;
@@ -366,6 +390,30 @@ fn setup_loading_screen(
..default()
},
));
// The tip of the day (decision 2077), drawn between the background quad and the
// progress bar — the reference's own order (`0x406e12`/`0x406e18`). The block is
// positioned in PERCENT of this 4:3 area, which is exactly the space
// `crate::game_tip`'s constants are in; only the font size and the shadow need pixels,
// and those follow the window each frame. Empty and hidden until a glue->world raise
// fills it.
area.spawn((
LoadingTip,
Text::new(String::new()),
TextLayout {
linebreak: LineBreak::WordBoundary,
justify: Justify::Left,
},
TextFont::default(),
TextColor(crate::game_tip::base_color()),
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
bottom: Val::Percent(0.0),
width: Val::Percent(0.0),
..default()
},
Visibility::Hidden,
));
// Fill — left-anchored; width = progress·FILL_MAX_WIDTH (set each frame). y from the
// BOTTOM (verified by screenshot + binary). The fill art is a horizontally-uniform
// gradient, so width-scaling reads as a left→right reveal.
@@ -486,6 +534,12 @@ fn drive_loading_screen(
if entered.read().next().is_some() && *state.get() != crate::char_select::ClientState::InWorld {
let map = roster.as_ref().and_then(|r| r.pending_map());
screen.raise("world entry", true, map, now);
// **The tip of the day rides THIS edge and no other** (decision 2077): the reference's
// setter `0x406630` has exactly one caller, inside `CGlueMgr::EnterWorld`, and neither
// `SMSG_TRANSFER_PENDING` arm reaches it — so a portal or worldport screen carries no tip.
// The pick itself is `crate::game_tip`'s, one system over; this list is at Bevy's
// sixteen-parameter ceiling and the tip needs three resources of its own.
screen.tip_edge = Some(TipEdge::Pick);
}
// A portal walk-in (`SMSG_TRANSFER_PENDING`, no transport): the server is about to unload us
// and the `SMSG_NEW_WORLD` snap follows after its own load — cover now, like the reference.
@@ -494,6 +548,7 @@ fn drive_loading_screen(
match &t.0 {
Some(info) if info.transport_entry.is_none() => {
screen.raise("transfer pending", true, Some(info.map_id), now);
screen.tip_edge = Some(TipEdge::Clear);
}
// The latch cleared with no snap in flight = `SMSG_TRANSFER_ABORTED` (a worldport
// clears it too, but that path also lands below this frame): stop awaiting, and the
@@ -507,6 +562,7 @@ fn drive_loading_screen(
// screen at all is the backstop's call (a summon across the room shouldn't flash one).
for w in worldports.read() {
screen.raise("worldport", false, Some(w.map_id), now);
screen.tip_edge = Some(TipEdge::Clear);
}
let teleported = teleports.read().next().is_some();
if teleported {
@@ -578,6 +634,7 @@ fn drive_loading_screen(
&& *state.get() == crate::char_select::ClientState::InWorld
{
screen.raise("focus not resident", false, None, now);
screen.tip_edge = Some(TipEdge::Clear);
}
// --- …and the same test at the SNAP, on everything the reveal actually needs. The backstop
@@ -606,6 +663,7 @@ fn drive_loading_screen(
&& !(progress.is_ready() && progress.presentable())
{
screen.raise("teleport, destination not presentable", false, None, now);
screen.tip_edge = Some(TipEdge::Clear);
}
// --- Clear: the destination snap has landed, the scene is presentable (tiles + focus
+611
View File
@@ -0,0 +1,611 @@
//! The two 1.12 text filters — `profanityFilter`'s **masker** and `spamFilter`'s **predicate**
//! (decision 2077; wow-re `system/ui/scratch/text-filter-law.md`, a §5 round of seven workers with
//! an emulated run of the binary's own compile/exec over the shipped lists).
//!
//! Both are **PCRE over shipped DBCs**, not word lists: `ChatProfanity.dbc` (2289 rows) feeds the
//! masker `0x4a1a60`, `SpamMessages.dbc` (28 gold-seller URL patterns) feeds the predicate
//! `0x4a1ca0`. The reference compiles them once at startup and never reloads.
//!
//! ## The masker (`0x4a1a60`) — in place, length-preserving, phase-carrying
//!
//! It **self-gates**: `[0x843600]` (the `profanityFilter` mirror) is read at `0x4a1a66`, at the head
//! of the transform, which is why all thirteen of its call sites are CVar-gated without any of them
//! testing the CVar. That is reproduced here — [`TextFilter::mask`] takes the switch, and no call
//! site repeats it.
//!
//! Per matched span it overwrites **one byte per byte** from `.rdata 0x806458` = `!@#$%^&*`,
//! advancing a **process-global index `[0xb6e5f0]` that is never reset**. So the mask phase carries
//! across matches, across messages and across subsystems for the whole session — a client that
//! restarts the cycle per message diverges visibly on the second censored word. Patterns apply in
//! **list order, not text order**, every occurrence, and the string's length never changes (a
//! multi-byte match becomes that many ASCII punctuation bytes, exactly as the reference's byte loop
//! does).
//!
//! **Wired here: the chat chokepoint only** — the fourteen social chat types of `.rdata 0x8046d8`,
//! which is the masker's largest consumer and the one the options row is about. The reference has
//! twelve more call sites and they are named rather than quietly skipped: `SMSG_MAIL_LIST_RESULT`'s
//! subject, `GetInboxText`, `GetGuildInfo`, the two guild-record accessors, `SMSG_GUILD_ROSTER`'s
//! MOTD and info buffers, `GetGuildRosterInfo`, `GuildControlGetRankName`, item text before
//! `ITEM_TEXT_READY`, `SendChatMessage`, and `SMSG_GUILD_EVENT`'s MOTD. Each needs the mask applied
//! where that text enters our model rather than at the Lua getter (benilla publishes a model where
//! the reference re-filters a raw store), which is a different piece of plumbing per subsystem.
//!
//! **The `dl = 1` memo lands with them, not before.** Seven of those twelve pass it, and it is
//! observable: a cache hit re-blits the stored text so the mask index does *not* advance, which is
//! what stops a roster string re-read every repaint from shimmering. Masking once as the text
//! enters the model is that same behaviour by construction, so the memo is a thing those sites will
//! need only if they mask at the getter. Shipping the leg now would be a method nothing calls.
//!
//! ## The predicate (`0x4a1ca0`) — a boolean, never an edit
//!
//! One call site image-wide, inside the chat chokepoint. It never touches the subject text; its
//! out-buffer is written and discarded, and its memo cache is dead code in this build. A positive
//! result **drops the line silently** — nothing is shown in its place.
//!
//! ## PCRE → `regex`, exactly
//!
//! Two translations, and both are the reference's own behaviour rather than a convenience:
//!
//! - **`\<` and `\>` are word boundaries, not literals.** Blizzard *patched* PCRE's escape table
//! (`.rdata 0x812330`) to give `\<`, `\>` and `\b` the identical code `−4`, so both are
//! non-directional `\b`. Stock PCRE has no `<`/`>` entries and would make them literals, which
//! would kill the entire ASCII half of `ChatProfanity`. wow-re settled this twice — at the table,
//! and by running the binary's own compiler on a reversal control (`\>twat\<` behaves exactly
//! like `\<twat\>`).
//! - **CASELESS folds ASCII only** — the fold table `0x873c40` is the identity over `0x80..=0xff`.
//! `regex`'s own `(?i)` folds Unicode, which would match `É` where the reference matches nothing,
//! so instead both the pattern's literals and the subject are ASCII-lowercased and the match runs
//! case-*sensitively*. ASCII lowercasing never touches a UTF-8 continuation byte, so every match
//! offset maps back to the original string unchanged.
//!
//! The shipped patterns use only `. < > s` after a backslash and `? [ ] * + ^ $ .` as metacharacters
//! — no backreferences, no lookaround — so `regex` runs them as PCRE would.
use bevy::prelude::*;
use regex::{Regex, RegexSet};
use benilla_formats::FilterPattern;
/// `.rdata 0x806458` — the eight mask bytes, in order.
const MASK_TOKENS: &[u8; 8] = b"!@#$%^&*";
/// One compiled list: the set (for the cheap "which patterns can match at all" pass) beside the
/// individual expressions, in **list order** — which is the order the mask law walks them in.
struct PatternList {
set: RegexSet,
each: Vec<Regex>,
}
impl PatternList {
/// Compile a list, **skipping a row that fails** exactly as `0x6c94cf`'s success-only increment
/// leg does — so the live count is the number that compiled, not the row count. On the shipped
/// data none fails; a row that does is named, because the reference names it too.
fn compile(rows: &[FilterPattern], list: &str) -> Self {
let mut patterns = Vec::with_capacity(rows.len());
let mut each = Vec::with_capacity(rows.len());
for row in rows {
let translated = translate(&row.pattern);
match Regex::new(&translated) {
Ok(re) => {
patterns.push(translated);
each.push(re);
}
Err(e) => warn!(
"skipping {list} expression {:?} (record ID {}): {e}",
row.pattern, row.id
),
}
}
// The set is built from the same strings that compiled one by one, so it cannot disagree
// with `each` about what is in the list.
let set = RegexSet::new(&patterns).unwrap_or_else(|e| {
error!("{list}: the pattern set would not build ({e}) — the filter is off");
RegexSet::empty()
});
PatternList { set, each }
}
fn empty() -> Self {
PatternList {
set: RegexSet::empty(),
each: Vec::new(),
}
}
fn len(&self) -> usize {
self.each.len()
}
}
/// **Translate one shipped PCRE pattern** into the equivalent `regex` expression: `\<`/`\>` become
/// `\b`, every other escape is preserved verbatim, and literal ASCII is lowercased so the match can
/// run case-sensitively against an ASCII-lowercased subject (the module doc says why).
///
/// The lowercasing deliberately does **not** reach the character after a backslash: `\S` and `\s`
/// are different classes, and folding one into the other would silently change a pattern. The
/// shipped lists only ever use `\.`, `\<`, `\>` and `\s`, so nothing rides on it today — but a
/// locale archive is free to ship a pattern that does.
fn translate(pattern: &str) -> String {
let mut out = String::with_capacity(pattern.len());
let mut chars = pattern.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c.to_ascii_lowercase());
continue;
}
match chars.next() {
// The patched escape table: both are the non-directional `\b`.
Some('<' | '>') => out.push_str("\\b"),
Some(next) => {
out.push('\\');
out.push(next);
}
// A trailing backslash is a compile error in PCRE too; let `Regex::new` report it.
None => out.push('\\'),
}
}
out
}
/// ASCII-only lowercase — the reference's `CASELESS` fold, whose table is the identity over
/// `0x80..=0xff`. Byte-for-byte, so every offset in the result names the same position in the
/// original (a UTF-8 continuation byte is never touched: it is always `>= 0x80`).
fn ascii_lower(text: &str) -> String {
let mut s = text.to_owned();
// SAFETY-free equivalent of `make_ascii_lowercase` on the String's bytes: only ASCII letters
// change, so UTF-8 validity is preserved by construction.
s.make_ascii_lowercase();
s
}
/// The two switches, mirrored off their CVars — the reference's `[0x843600]` (`profanityFilter`)
/// and `[0x843604]` (`spamFilter`), each written by that CVar's own change callback.
///
/// Both default **on**: `CVar::Register` at `0x402e68` and `0x402e8e` pushes the shared `"1"`
/// literal `0x82e748` for each, and the registrar runs the callback on the registered default, so a
/// fresh client boots with both mirrors set.
#[derive(Resource, Debug, Clone, Copy)]
pub(crate) struct TextFilterSwitches {
/// `profanityFilter` — the masker's self-gate.
pub(crate) profanity: bool,
/// `spamFilter` — the chat chokepoint's spam arm. The options row for it is **inverted**
/// ("Disable Spam Filter"), which is the row's business, not this flag's.
pub(crate) spam: bool,
}
impl Default for TextFilterSwitches {
fn default() -> Self {
TextFilterSwitches {
profanity: true,
spam: true,
}
}
}
/// The 14 chat types the reference's profanity arm is eligible for — `.rdata 0x8046d8`, read as a
/// table of 14 dwords with the bound `0x38` at `0x49aabb`, i.e. **every player-authored social
/// channel**. A type outside this table is never masked, which is why a monster's yell and a
/// combat-log line reach the frame verbatim.
pub(crate) const PROFANITY_MASKED_CHAT_TYPES: &[u8] = &[
0x00, // SAY
0x01, // PARTY
0x02, // RAID
0x03, // GUILD
0x04, // OFFICER
0x05, // YELL
0x06, // WHISPER
0x07, // WHISPER_INFORM
0x08, // EMOTE
0x0e, // CHANNEL
0x14, // AFK
0x15, // DND
0x58, // RAID_WARNING
0x5c, // BATTLEGROUND
];
/// The two compiled lists plus the state the reference keeps beside them: the rotating mask index
/// and the masker's per-string memo.
#[derive(Resource)]
pub(crate) struct TextFilter {
profanity: PatternList,
spam: PatternList,
/// `[0xb6e5f0]` — BSS, zero-init, advanced mod 8, **never reset**.
mask_index: usize,
}
impl Default for TextFilter {
/// An engine with no lists — what a machine with no client data gets. Both filters then pass
/// everything through, which is the same shape as the reference booting with an empty list.
fn default() -> Self {
TextFilter {
profanity: PatternList::empty(),
spam: PatternList::empty(),
mask_index: 0,
}
}
}
impl TextFilter {
pub(crate) fn new(profanity: &[FilterPattern], spam: &[FilterPattern]) -> Self {
TextFilter {
profanity: PatternList::compile(profanity, "chat profanity filter"),
spam: PatternList::compile(spam, "chat spam filter"),
mask_index: 0,
}
}
pub(crate) fn profanity_len(&self) -> usize {
self.profanity.len()
}
pub(crate) fn spam_len(&self) -> usize {
self.spam.len()
}
/// `0x4a1ca0` — **does this text match the spam list?** A predicate, never an edit.
///
/// The reference scans the shipped list then a server-pushed one (SMSG `0x332`) which starts
/// empty and stays empty unless the server sends it; vmangos never does, so there is one list
/// here. It does not exit early on a hit, but the only consumed output is the boolean, so a
/// first-match-wins scan is observably identical.
///
/// `enabled` is the caller's `spamFilter`; the reference tests it *outside* this function (the
/// chokepoint's `esi`), unlike the masker's self-gate.
pub(crate) fn is_spam(&self, enabled: bool, text: &str) -> bool {
if !enabled || text.is_empty() || self.spam.len() == 0 {
return false;
}
self.spam.set.is_match(&ascii_lower(text))
}
/// `0x4a1a60` with `dl = 0` — mask in place, return whether anything matched.
///
/// **Self-gating, like the reference**: `enabled` is `profanityFilter`, tested here so no call
/// site has to.
pub(crate) fn mask(&mut self, enabled: bool, text: &mut String) -> bool {
if !enabled || text.is_empty() || self.profanity.len() == 0 {
return false;
}
self.mask_uncached(text)
}
/// The transform itself — the byte loop of `0x4a1b6a`–`0x4a1ba6` under `0x4a1a60`'s pattern
/// walk.
///
/// **Single pass, list order, over the mutating buffer.** The reference walks every pattern in
/// order against the buffer it is masking, so a pattern that only becomes matchable because an
/// *earlier* pattern masked something is caught, while one that would need a *later* pattern's
/// mask is not — it has already been passed. That asymmetry is reproduced here rather than
/// smoothed: the candidate set is re-derived after each pattern that actually wrote, and only
/// entries past the current one are kept.
fn mask_uncached(&mut self, text: &mut String) -> bool {
// The matching subject: ASCII-lowercased, byte-aligned with `text`, and masked in step so
// later patterns see what the reference's later patterns see.
let mut subject = ascii_lower(text);
let mut candidates: Vec<usize> = self.profanity.set.matches(&subject).iter().collect();
let mut any = false;
let mut i = 0;
while i < candidates.len() {
let p = candidates[i];
let mut wrote = false;
let mut cursor = 0;
while let Some(m) = self.profanity.each[p].find_at(&subject, cursor) {
let (start, end) = (m.start(), m.end());
any = true;
if start == end {
// The reference latches "something matched" before the length test and then
// re-runs from an unadvanced cursor — i.e. an empty-matching pattern spins.
// No shipped row can match empty (the test below holds that), so this is the
// one place we decline to reproduce the reference and simply stop.
break;
}
// One token per BYTE of the span — the reference's `mov [esi],cl / inc esi / dec
// eax` loop, so a three-byte character becomes three punctuation marks. The span is
// on char boundaries (a regex match always is) and the run is the same byte length,
// so both strings stay valid UTF-8 and stay byte-aligned with each other.
let run: String = (start..end)
.map(|_| {
let token = MASK_TOKENS[self.mask_index] as char;
self.mask_index = (self.mask_index + 1) % MASK_TOKENS.len();
token
})
.collect();
text.replace_range(start..end, &run);
subject.replace_range(start..end, &run);
wrote = true;
cursor = end;
if cursor >= subject.len() {
break;
}
}
if wrote {
// The buffer moved: whatever can still match now is what the reference's remaining
// patterns would see. Keep only the ones it has not walked past.
candidates = self
.profanity
.set
.matches(&subject)
.iter()
.filter(|&c| c > p)
.collect();
i = 0;
} else {
i += 1;
}
}
any
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list(patterns: &[&str]) -> Vec<FilterPattern> {
patterns
.iter()
.enumerate()
.map(|(i, p)| FilterPattern {
id: i as u32 + 1,
pattern: (*p).to_string(),
})
.collect()
}
/// `\<` and `\>` are **word boundaries**, and non-directional — wow-re's reversal control,
/// which could have failed: the reversed spelling behaves identically to the ordinary one and
/// to `\b`, and none of the three matches inside a word.
#[test]
fn the_angle_escapes_are_non_directional_word_boundaries() {
for spelling in [r"\<twat\>", r"\>twat\<", r"\btwat\b"] {
let f = TextFilter::new(&list(&[spelling]), &[]);
let mut hit = "you twat here".to_string();
let mut miss = "atwatb".to_string();
let mut copy = f;
assert!(copy.mask(true, &mut hit), "{spelling} must match [4,8)");
assert_eq!(hit, "you !@#$ here", "{spelling}");
assert!(
!copy.mask(true, &mut miss),
"{spelling} must not match inside a word"
);
assert_eq!(miss, "atwatb");
}
}
/// The mask is **in place, length-preserving, case-insensitive and unanchored where the pattern
/// is** — and the phase **carries across calls**. This is wow-re's oracle transcript, run
/// against the binary's own bytes, reproduced pattern for pattern.
///
/// The list order is the oracle's: `\<fagg[aeiouy]t` before `\<twat\>`, which is what makes the
/// *later* word in the sentence take the *earlier* mask characters. Had the client scanned in
/// text order the assignment would come out the other way round — the control that could have
/// failed.
#[test]
fn the_masker_reproduces_the_oracle_transcript() {
let mut f = TextFilter::new(&list(&[r"\<fagg[aeiouy]t", r"\<twat\>", "shit"]), &[]);
let mut line = "you are a twat and a faggot".to_string();
assert!(f.mask(true, &mut line));
assert_eq!(line, "you are a &*!@ and a !@#$%^");
let mut line = "oh shit that hurts".to_string();
assert!(f.mask(true, &mut line));
assert_eq!(line, "oh #$%^ that hurts");
let mut line = "shit shit shit".to_string();
assert!(f.mask(true, &mut line));
assert_eq!(line, "&*!@ #$%^ &*!@");
// Case-insensitive, and unanchored inside a word.
let mut line = "BULLSHIT".to_string();
assert!(f.mask(true, &mut line));
assert_eq!(line, "BULL#$%^");
let mut line = "hello friend, nice weather".to_string();
assert!(!f.mask(true, &mut line));
assert_eq!(line, "hello friend, nice weather");
assert_eq!(
f.mask_index, 6,
"the cycle carries forward, and is never reset"
);
}
/// The switch is read **inside** the masker, which is why none of the thirteen call sites tests
/// it: with `profanityFilter = 0` the text is untouched and the phase does not move.
#[test]
fn the_masker_self_gates_and_a_gated_call_does_not_move_the_phase() {
let mut f = TextFilter::new(&list(&[r"\<twat\>"]), &[]);
let mut line = "you are a twat".to_string();
assert!(!f.mask(false, &mut line));
assert_eq!(line, "you are a twat");
assert_eq!(f.mask_index, 0);
}
/// A multi-byte match is masked **byte for byte**: the reference's loop writes one ASCII token
/// per byte of the span, so a three-byte character becomes three punctuation marks and the
/// string's byte length never changes.
#[test]
fn a_non_ascii_match_is_masked_byte_for_byte() {
let mut f = TextFilter::new(&list(&["傻B"]), &[]);
let mut line = "说 傻B 了".to_string();
let before = line.len();
assert!(f.mask(true, &mut line));
assert_eq!(line.len(), before, "length-preserving in BYTES");
assert_eq!(line, "说 !@#$ 了", "3 bytes of 傻 + 1 of B = four tokens");
}
/// The predicate never edits, and it is off when the switch is.
#[test]
fn the_spam_predicate_is_a_boolean_over_the_untouched_text() {
let spam = list(&[
r"(w\s*w\s*w\s*\.)?\s*i\s*t\s*e\s*m\s*b\s*a\s*y\s*(\.\s*c\s*a)",
r"(w\s*w\s*w\s*\.)?\s*g\s*m\s*w\s*o\s*r\s*k\s*e\s*r\s*(\.\s*c\s*o\s*m\s*)",
]);
let f = TextFilter::new(&[], &spam);
assert!(f.is_spam(true, "buy gold at www.itembay.ca cheap"));
assert!(f.is_spam(true, "w w w . i t e m b a y . c a"));
assert!(f.is_spam(true, "W W W . I T E M B A Y . C A"), "CASELESS");
assert!(f.is_spam(true, "visit gmworker.com now"));
assert!(!f.is_spam(true, "hello friend"));
assert!(
!f.is_spam(true, "you are a twat"),
"the two lists are disjoint"
);
assert!(
!f.is_spam(false, "buy gold at www.itembay.ca"),
"the switch is off"
);
}
/// The shipped lists, compiled — every row, through the real chain. Two things are held: the
/// translation compiles all 2317 rows (the reference's own oracle measured zero failures, so a
/// skipped row here would be ours, not the data's), and **no shipped pattern can match the
/// empty string**, which is the assumption `mask_uncached`'s zero-length break rests on.
#[test]
fn every_shipped_pattern_compiles_and_none_matches_empty() {
let Some(data) = benilla_formats::wow_data() else {
return;
};
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let profanity = benilla_formats::load_chat_profanity(&mut chain).expect("profanity");
let spam = benilla_formats::load_spam_messages(&mut chain).expect("spam");
let f = TextFilter::new(&profanity, &spam);
assert_eq!(
f.profanity_len(),
profanity.len(),
"every profanity row compiled"
);
assert_eq!(f.spam_len(), spam.len(), "every spam row compiled");
for (i, re) in f.profanity.each.iter().enumerate() {
assert!(
!re.is_match(""),
"profanity row {i} matches the empty string: {}",
re.as_str()
);
}
}
/// The shipped data end to end: the oracle's own sentences through the real lists.
#[test]
fn the_shipped_lists_reproduce_the_oracle_sentences() {
let Some(data) = benilla_formats::wow_data() else {
return;
};
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let profanity = benilla_formats::load_chat_profanity(&mut chain).expect("profanity");
let spam = benilla_formats::load_spam_messages(&mut chain).expect("spam");
let mut f = TextFilter::new(&profanity, &spam);
let mut line = "you are a twat and a faggot".to_string();
assert!(f.mask(true, &mut line));
assert_eq!(
line, "you are a &*!@ and a !@#$%^",
"list order beats text order on the real list too"
);
let mut clean = "hello friend, nice weather".to_string();
assert!(!f.mask(true, &mut clean));
assert_eq!(clean, "hello friend, nice weather");
assert!(f.is_spam(true, "buy gold at www.itembay.ca"));
assert!(!f.is_spam(true, "hello friend"));
}
}
/// The chat chokepoint's view of the two filters — the engine and its switches in one
/// [`SystemParam`], because a Bevy system takes at most sixteen parameters and `feed_chat` had
/// already reached fifteen (the `SpeakerEffects` precedent, same file).
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct ChatTextFilter<'w> {
filter: ResMut<'w, TextFilter>,
switches: Res<'w, TextFilterSwitches>,
}
impl ChatTextFilter<'_> {
/// Arm 1 — should this line be **dropped**? (`0x49aac9`–`0x49ab33`.)
///
/// The conjuncts, in the reference's own order: the type is not `CHAT_MSG_FILTERED` (`0x5b` —
/// the type the filter itself produces, so re-filtering it would be circular), the line does
/// not carry the `"GM"` chat tag, the viewer is not a GM, and `spamFilter` is on. Only then is
/// the predicate run.
///
/// **A GM viewer skips this arm and still reaches the mask** — all three of the spam arm's
/// exits converge on `0x49abb7`, the profanity arm's entry, not on the epilogue `0x49afd7`.
/// (wow-re settled that at the bytes after the note first said "bypassing both arms"; the
/// reductio is that the wrong reading would make `spamFilter = 0` disable profanity masking
/// too.)
pub(crate) fn should_drop(
&self,
chat_type: u8,
chat_tag: u8,
viewer_is_gm: bool,
text: &str,
) -> bool {
use benilla_protocol::messages::{chat_tag as tag, CHAT_MSG_FILTERED};
if chat_type == CHAT_MSG_FILTERED || chat_tag == tag::GM || viewer_is_gm {
return false;
}
self.filter.is_spam(self.switches.spam, text)
}
/// Arm 2 — mask the line, if its type is one of the fourteen the reference's table names.
///
/// Eligibility is the call site's (`[ebp-8]`, computed at `0x49aa98`–`0x49aac2`); the CVar is
/// the masker's own (`0x4a1a66`), so it is not tested here.
pub(crate) fn mask_chat(&mut self, chat_type: u8, text: &mut String) {
if !PROFANITY_MASKED_CHAT_TYPES.contains(&chat_type) {
return;
}
self.filter.mask(self.switches.profanity, text);
}
}
/// Load both lists off the patch chain, once, and install the engine.
///
/// The reference does this in its linear startup init (`0x402b7f → 0x6c91a0`) with **no reload
/// path**, so a `Startup` system is the same shape. `.after(AssetSet::Open)` is load-bearing for
/// the reason every other DBC load in this tree carries it: without it the chain does not exist
/// yet, the `Option<Res<_>>` takes its `None` arm, and the filters are silently empty forever.
fn load_text_filter_lists(
mut commands: Commands,
assets: Option<Res<benilla_assets::WorldAssets>>,
) {
let Some(assets) = assets else { return };
let (profanity, spam) = {
use benilla_assets::LockRecover;
let mut chain = assets.chain.lock_recover();
(
benilla_formats::load_chat_profanity(&mut chain),
benilla_formats::load_spam_messages(&mut chain),
)
};
// A missing list is a filter that passes everything, not a boot failure — the same posture the
// reference has when a row will not compile: name it and carry on with what did.
let profanity = profanity
.inspect_err(|e| warn!("chat profanity filter unavailable: {e:#}"))
.unwrap_or_default();
let spam = spam
.inspect_err(|e| warn!("chat spam filter unavailable: {e:#}"))
.unwrap_or_default();
let filter = TextFilter::new(&profanity, &spam);
info!(
"chat filters: {} profanity expressions, {} spam expressions",
filter.profanity_len(),
filter.spam_len()
);
commands.insert_resource(filter);
}
pub(crate) struct TextFilterPlugin;
impl Plugin for TextFilterPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TextFilterSwitches>()
.init_resource::<TextFilter>()
.add_systems(
Startup,
load_text_filter_lists.after(benilla_assets::AssetSet::Open),
);
}
}
+11
View File
@@ -64,6 +64,9 @@ pub(crate) enum ChatEventKind {
RaidLeader,
RaidWarning,
RaidBossEmote,
/// `CHAT_MSG_FILTERED` (`0x5B`) — the server's "your message was filtered" notice, whose
/// `arg2` is the addressee the stock frame formats `CHAT_FILTERED` over (2077).
Filtered,
Battleground,
BattlegroundLeader,
BgSystemNeutral,
@@ -260,6 +263,7 @@ impl ChatEventKind {
K::RaidLeader,
K::RaidWarning,
K::RaidBossEmote,
K::Filtered,
K::Battleground,
K::BattlegroundLeader,
K::BgSystemNeutral,
@@ -490,6 +494,7 @@ pub(crate) fn event_name(kind: ChatEventKind) -> &'static str {
K::RaidLeader => "CHAT_MSG_RAID_LEADER",
K::RaidWarning => "CHAT_MSG_RAID_WARNING",
K::RaidBossEmote => "CHAT_MSG_RAID_BOSS_EMOTE",
K::Filtered => "CHAT_MSG_FILTERED",
K::Battleground => "CHAT_MSG_BATTLEGROUND",
K::BattlegroundLeader => "CHAT_MSG_BATTLEGROUND_LEADER",
K::BgSystemNeutral => "CHAT_MSG_BG_SYSTEM_NEUTRAL",
@@ -641,6 +646,11 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] {
K::ChannelJoin | K::ChannelLeave | K::ChannelList => [192, 128, 128],
K::ChannelNotice | K::ChannelNoticeUser => [192, 192, 192],
K::Ignored => [255, 0, 0],
// `ChatTypeInfo["FILTERED"] = { sticky = 0 }` (ChatFrame.lua l.112) carries **no colour**,
// so the stock frame passes nil r/g/b to `AddMessage` and the line takes the window's own
// default. White is that default, and this table's consumers (the bubble tint, the edit
// box header) never see this kind anyway — it is not a speech line.
K::Filtered => [255, 255, 255],
K::Skill => [85, 85, 255],
K::Loot => [0, 170, 0],
K::Money => [255, 255, 0],
@@ -746,6 +756,7 @@ pub(crate) fn kind_of_wire(chat_type: u8) -> Option<ChatEventKind> {
m::CHAT_MSG_RAID_LEADER => K::RaidLeader,
m::CHAT_MSG_RAID_WARNING => K::RaidWarning,
m::CHAT_MSG_RAID_BOSS_EMOTE => K::RaidBossEmote,
m::CHAT_MSG_FILTERED => K::Filtered,
m::CHAT_MSG_BATTLEGROUND => K::Battleground,
m::CHAT_MSG_BATTLEGROUND_LEADER => K::BattlegroundLeader,
m::CHAT_MSG_BG_SYSTEM_NEUTRAL => K::BgSystemNeutral,
+36 -1
View File
@@ -720,6 +720,10 @@ pub(super) fn feed_chat(
// (`TRADESKILL_LOG`, `FEEDPET_LOG`, `ITEMENCHANTMENT*`, `SPELLDURABILITYDAMAGE`) resolve it
// here, through the same ask-once cache the reference's own deferred queue re-runs against.
mut items: ResMut<crate::items::Items>,
// The two 1.12 text filters (decision 2077), bundled for the same reason `SpeakerEffects` is:
// this list is at the sixteen-parameter ceiling. This IS the reference's `0x49a870`, so both
// arms belong here and nowhere else.
mut text_filter: crate::text_filter::ChatTextFilter,
) {
let Some(mut script) = script else {
return;
@@ -832,7 +836,38 @@ pub(super) fn feed_chat(
// again, so an addon receiving a foreign-language line cannot recover the
// plaintext. Ours cannot either, deliberately.
let language = langs.effective_language(msg.chat_type, msg.language);
let text = langs.garble(language, &plain);
let mut text = langs.garble(language, &plain);
// ── The two text filters, in the reference's own order (decision 2077) ─────────
//
// They run HERE, on the one buffer the garble just filled, because that is where
// `0x49a870` runs them: it fills `[ebp-0xd0c]` once (plain copy or garble) and
// both arms operate on that buffer, so a filtered line is filtered in the language
// the viewer actually reads.
//
// **Spam first, mask second, fire third**, and the order is not cosmetic: a line
// the spam filter drops is never masked, so its characters must not consume mask
// indices — the mask phase is a process-global that carries across every message
// and subsystem, and masking before deciding to drop would drift it.
if text_filter.should_drop(msg.chat_type, msg.chat_tag, langs.is_gm(), &text) {
// **Nothing is shown in its place.** `0x49ab33`'s not-a-whisper leg jumps to
// `0x49afd7`, the bare epilogue — no event, no substitute text, no chat line.
//
// NOT built, and named rather than faked: the whisper leg. A dropped
// `CHAT_MSG_WHISPER` with a non-zero report guid also sends `CMSG_CHAT_FILTERED`
// (`0x331`) before returning — still firing no event, so the player sees the
// same nothing either way. The packet's BODY is not settled (wow-re parked
// `0x508680`'s send-path verdict as a DEFERRED), vmangos has no handler for the
// opcode, and inventing a body to send a server that ignores it would be worse
// than the honest gap.
debug!(
"chat: spam filter dropped [{:#04x}] {:?}",
msg.chat_type, text
);
continue;
}
text_filter.mask_chat(msg.chat_type, &mut text);
let text = text;
let mut event = ChatEvent {
kind,
text: text.clone(),
@@ -84,6 +84,14 @@ impl ChatLanguages {
/// The addon sentinel is *not* handled here: `language == -1` never reaches this path at all
/// (it is dropped upstream as addon traffic, decision 1029), and it is a `u32` by the time we
/// see it.
/// **Is the viewer a GM?** — `PLAYER_FLAGS & 0x8`, read at the same `0x49a9cc` site the garble
/// gate reads it at. The chat chokepoint's spam arm needs the identical bit (`0x49ab03`), and
/// it is already here, so it is answered from here rather than re-derived from a second
/// descriptor query (decision 2077).
pub(crate) fn is_gm(&self) -> bool {
self.gm
}
pub(crate) fn effective_language(&self, chat_type: u8, language: u32) -> u32 {
if self.gm || !self.have_player || ALWAYS_UNIVERSAL.contains(&chat_type) {
return 0;
+3 -1
View File
@@ -932,7 +932,9 @@ fn every_kind_is_in_all() {
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), before, "a kind is listed twice in ALL");
assert_eq!(before, 92, "92 kinds — update this when the kind set grows");
// 93 since 2077: `CHAT_MSG_FILTERED` (`0x5B`) is a real server line, not the never-wire value
// this tree had it filed as — the server's "your message was filtered" notice.
assert_eq!(before, 93, "93 kinds — update this when the kind set grows");
}
#[test]
@@ -798,6 +798,10 @@ fn interface_harness() -> UiScript {
"Interface\\FrameXML\\QuestFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\QuestLogFrame.xml",
// The Show Tutorials row's setter is the reference's own Save arm, which reaches
// `TutorialFrameCheckButton` and `TutorialFrame_HideAllAlerts` UNGUARDED (2077) — so
// the page's harness loads the window that declares them, as the client does.
"Interface\\FrameXML\\TutorialFrame.xml",
],
);
harness_on(s)
@@ -2299,13 +2303,13 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 1493's; Terrain Distance 1513's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 6 (3 from
// 1136, Buff Durations 1139, the target-of-target pair 1576), the Action Bars page's 2 (the
// lock 1136, Always Show
// ActionBars 1500) and the Chat page's 1 (Remove Chat Hover Delay, 1589) + 6 API rows (the Interface page's Show Cloak / Show Helm, 1472; the Action
// Bars page's four multibar switches, 1500) — which is the point of counting here rather than
// ActionBars 1500) and the Chat page's 1 (Remove Chat Hover Delay, 1589) + 7 API rows (the Interface page's Show Cloak / Show Helm, 1472 and
// Show Tutorials, 2077; the Action Bars page's four multibar switches, 1500) — which is the point of counting here rather than
// per page: the third store's rows are held to the same "the key is 1.12's own and it resolves"
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
// their other entries ride the same census as the selection moves.
// 55 of the 58 are 1.12's own; the other three are Render Scale (1639), Display Mode
// 59 of the 62 are 1.12's own; the other three are Render Scale (1639), Display Mode
// (1650) and Enable Sound in Background (1847), whose descriptions are benilla's and whose
// carve-out is above.
// The 28th CVar row is Block Trades (1764), on the Controls page — its key
@@ -2313,7 +2317,12 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 29th is Enable Error Speech (1815), 1.12's own fourth Sound checkbox, ditto; the 30th is
// Enable Sound in Background (1847), the Audio page's fifth checkbox and the one row on that
// page the reference never made settable.
assert_eq!(checked, 58, "every tipped row carries a live key");
// The 7th API row is Show Tutorials (2077) — the Interface page's Help box, whose store is
// `TutorialsEnabled()` / `ResetTutorials()` / `ClearTutorials()` rather than a CVar, and whose
// key OPTION_TOOLTIP_SHOW_TUTORIALS is 1.12's own.
// The 30th and 31st CVar rows are the two text filters (2077): Profanity Filter on the
// Interface page and Disable Spam Filter on the Chat page, both keys 1.12's own.
assert_eq!(checked, 62, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2423,7 +2432,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// rather than 1.12 GlobalStrings — see the guard above.
// …and Block Trades (1764), the Controls page's 28th CVar row, and Enable Error Speech
// (1815), the Audio page's fourth checkbox and 1.12's own.
assert_eq!(raised, 58, "every row but Auto Loot raises a description");
// …and Show Tutorials (2077), the Interface page's seventh API row.
// …and the two text-filter rows (2077).
assert_eq!(raised, 62, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -3034,6 +3045,90 @@ fn the_equipment_display_rows_read_and_write_through_the_api_not_a_store() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Show Tutorials** (decision 2077) — the fourth `func` row, and the one 1.12's own table hides:
/// its `UIOptionsFrameCheckButtons` entry is a bare `{ index = 28 }`, so a reading of the table
/// alone (decision 1140's census) files it as unbound. The store is the three special-case arms —
/// `TutorialsEnabled()` reads it, `ResetTutorials()`/`ClearTutorials()` write it — which is what
/// this asserts: the row reads the acknowledged bank, ticking it OFF clears, ticking it back ON
/// resets, and the reference's `~=` guard keeps a no-op click from re-arming every dismissed
/// tutorial.
#[test]
fn show_tutorials_reads_the_bank_and_writes_through_clear_and_reset() {
let mut s = interface_harness();
// A bank with unacknowledged bits: tutorials are enabled.
s.set_tutorial_bank(Some(vec![0x00; 32]));
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the row reads TutorialsEnabled(), not a stored value"
);
let _ = s.take_cvar_changes();
let _ = s.take_tutorial_clears();
let _ = s.take_tutorial_resets();
// Ticking it off is `ClearTutorials()` — every bit acknowledged.
s.run("OptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:Click()")
.unwrap();
assert_eq!(s.take_tutorial_clears(), 1, "off clears the bank");
assert_eq!(s.take_tutorial_resets(), 0);
assert!(
s.take_cvar_changes().is_empty(),
"there is no tutorial CVar in 1.12 — the row must not invent one"
);
// The app answers by pushing the cleared bank back; the row now reads off.
s.set_tutorial_bank(Some(vec![0xFF; 32]));
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
!s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the revisit re-asks the getter"
);
// Ticking it back on is `ResetTutorials()`.
s.run("OptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:Click()")
.unwrap();
assert_eq!(s.take_tutorial_resets(), 1, "on resets the bank");
assert_eq!(s.take_tutorial_clears(), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's `~=` guard, which is the whole reason its Save arm is not a bare pair of calls:
/// setting the row to the value it already holds must send NOTHING. Without it, a Defaults click on
/// a page where tutorials are already on would `ResetTutorials()` and re-arm every popup the player
/// has dismissed — an option that silently undoes hours of play.
#[test]
fn a_no_op_write_does_not_re_arm_the_tutorials() {
let mut s = interface_harness();
s.set_tutorial_bank(Some(vec![0x00; 32])); // enabled
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
let _ = s.take_tutorial_clears();
let _ = s.take_tutorial_resets();
// Defaults writes "1" over a row that already reads "1".
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(
(s.take_tutorial_resets(), s.take_tutorial_clears()),
(0, 0),
"the guard held: no bank write for a value that had not moved"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on an API row: both boxes go back to shown, and the flip is sent **only** for the one
/// that was actually off — the setter is a *set* over a wire verb that is a blind *toggle*, so a
/// no-op default must not queue a packet that would turn the preference on its head.
@@ -3329,6 +3424,78 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
/// click queues the flag the host drains onto `BubbleConfig`, and the two are independent (the
/// client gates party lines on their own CVar, which is why party bubbles survive turning
/// say/yell bubbles off).
/// **Disable Spam Filter** (decision 2077) — the reference's own *inverted* row, and the one shape
/// on this page that would read backwards if it were wired like its neighbours.
///
/// `spamFilter` registers `"1"` (the filter ON), and the label says *Disable*: 1.12 checks the box
/// when `GetCVar == "0"` (`UIOptionsFrame.lua` l.253) and flips the value before writing it
/// (l.337). So a fresh client shows the box **unchecked**, and ticking it turns the filter off.
#[test]
fn the_disable_spam_filter_row_is_inverted() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowChat:Click()").unwrap();
assert_eq!(
s.cvar("spamFilter").as_deref(),
Some("1"),
"the registered default is the filter ON"
);
assert!(
!s.eval::<bool>(
"return OptionsFrameContainerBodyChatRowSpamFilterCheck:GetChecked() and true or false"
)
.unwrap(),
"a filter that is ON shows *Disable Spam Filter* unchecked"
);
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerBodyChatRowSpamFilterCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("spamFilter".to_string(), "0".to_string())],
"ticking *Disable* writes the CVar OFF"
);
assert_eq!(s.cvar("spamFilter").as_deref(), Some("0"));
// And back: the row round-trips rather than latching.
s.run("OptionsFrameContainerBodyChatRowSpamFilterCheck:Click()")
.unwrap();
assert_eq!(s.cvar("spamFilter").as_deref(), Some("1"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Profanity Filter** (decision 2077) — an ordinary CVar row, asserted because its *seat* is the
/// surprising part: 1.12 files it under Basic Options' **Display** box (CheckButton5 anchors under
/// CheckButton66), not with the chat settings its name suggests, so it closes the Interface page's
/// column-C chain rather than joining the Chat page.
#[test]
fn the_profanity_filter_row_sits_on_the_interface_page_and_writes_its_cvar() {
let mut s = interface_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowProfanityFilterCheck:GetChecked() \
and true or false"
)
.unwrap(),
"registered \"1\" — a stock client boots with profanity masking on"
);
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerBodyInterfaceRowProfanityFilterCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("profanityFilter".to_string(), "0".to_string())],
"not inverted — the label and the CVar agree"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn the_chat_page_toggles_the_chat_bubble_cvars() {
// No host override: since 1804 the registered pair IS bubbles-on / party-off, so the page's
@@ -4077,3 +4244,119 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The reference table's CVar census (decision 2077) ────────────────────────────────────────────
//
// `UIOptionsFrameCheckButtons` in `OptionsFrame.xml` is a transcription of the reference's own
// table (`UIOptionsFrame.lua` l.4-84), and every entry names its store. Twenty-eight of them name a
// **CVar** — and a name in that table is a claim: `SetCVar(value.cvar, …)` against a CVar this
// client never registered writes nothing and reads back nil, so the entry is a setting benilla
// appears to offer and does not.
//
// Decision 1140 §4 wrote that census as prose in a decision record, and prose in a record is a
// snapshot: by the time anyone looked again it said SHOW_TUTORIALS had "no store at all" (it has
// three special-case arms, 2077) and had been amended once already for the multibar rows. So the
// census lives here instead, where it cannot go stale — the same posture as `cvars::Reference`,
// and checked in **both directions**: an unregistered name missing from this list fails, and a
// listed name that has since been registered fails too.
//
// A row on this list is not a defect. It is a 1.12 setting whose *feature* benilla does not have,
// and the reason is the row's whole point — "no key without a reader" (1134 §4) is why the CVar is
// absent, and this is where that decision is written down per setting.
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
("autointeract", "click-to-move"),
("assistAttack", "assist-attack"),
("autoClearAFK", "no auto-clear of the AFK flag"),
("UnitNamePlayerGuild", "guild names over player nameplates"),
(
"UnitNamePlayerPVPTitle",
"PvP titles over player nameplates",
),
("cameraTerrainTilt", "camera terrain tilt"),
("cameraBobbing", "camera head bob"),
("cameraWaterCollision", "camera water collision"),
("cameraPivot", "the smart-pivot camera"),
(
"CombatDamage",
"the floating-combat-text master gate is `combat_text::law::COMBAT_DAMAGE`, a const bool \
— the same shape ChatBubbles had before 1139: a faithful gate with no way to reach it",
),
("CombatLogPeriodicSpells", "a combat log"),
(
"PetMeleeDamage",
"`combat_text::law::PET_MELEE_DAMAGE`, a const bool — see CombatDamage",
),
];
/// **Every CVar the reference's own options table names is registered here, or listed above with
/// the feature it waits on** (decision 2077) — the check that turns 1140 §4's prose census into
/// something that cannot rot, in both directions.
///
/// This is the instrument for the whole class: a table entry that names a CVar benilla does not
/// register is a row that would tick and do nothing, and until now the only thing standing between
/// us and shipping one was somebody re-reading a decision record from months ago.
#[test]
fn every_cvar_the_reference_table_names_is_registered_or_listed_with_its_blocker() {
let s = harness();
// The table as the file declares it: `name` (the display key) -> its `cvar`, if it has one.
let named: Vec<String> = s
.eval::<Vec<String>>(
"local out = {} \
for key, v in pairs(UIOptionsFrameCheckButtons) do \
if v.cvar then table.insert(out, v.cvar) end \
end \
table.sort(out) \
return out",
)
.expect("read UIOptionsFrameCheckButtons");
assert!(
named.len() >= 28,
"the transcription lost rows: only {} cvar entries",
named.len()
);
let registered: std::collections::HashSet<String> = crate::cvars::registered_pairs()
.map(|(n, _)| n.to_ascii_lowercase())
.collect();
let listed: std::collections::HashSet<String> = UNBACKED_REFERENCE_CVARS
.iter()
.map(|(n, _)| n.to_ascii_lowercase())
.collect();
let mut unlisted: Vec<&str> = Vec::new();
for cvar in &named {
let key = cvar.to_ascii_lowercase();
if !registered.contains(&key) && !listed.contains(&key) {
unlisted.push(cvar);
}
}
assert!(
unlisted.is_empty(),
"these rows name a CVar nothing registers, and nothing says why: {unlisted:?} — either \
build the backing or add the row to UNBACKED_REFERENCE_CVARS with its blocker"
);
// The other direction: a listed name that IS registered now means the feature landed and the
// row is stale — exactly the drift `cvars::Reference` guards against on its own column.
let stale: Vec<&str> = UNBACKED_REFERENCE_CVARS
.iter()
.map(|(n, _)| *n)
.filter(|n| registered.contains(&n.to_ascii_lowercase()))
.collect();
assert!(
stale.is_empty(),
"these are registered now — drop them from UNBACKED_REFERENCE_CVARS and give them a row: \
{stale:?}"
);
// And a listed name the table never mentions is a row describing nothing.
let phantom: Vec<&str> = UNBACKED_REFERENCE_CVARS
.iter()
.map(|(n, _)| *n)
.filter(|n| !named.iter().any(|c| c.eq_ignore_ascii_case(n)))
.collect();
assert!(
phantom.is_empty(),
"UNBACKED_REFERENCE_CVARS names CVars the reference's table does not: {phantom:?}"
);
}
@@ -2345,11 +2345,6 @@ mod tests {
("PET_UI_CLOSE", "PetPaperDollFrame.lua"),
("PET_UI_UPDATE", "PetPaperDollFrame.lua"),
("PLAYER_DAMAGE_DONE_MODS", "PaperDollFrame.lua"),
(
"PLAYER_FLAGS_CHANGED",
"TargetFrame.lua — the AFK/DND badge",
),
("PLAYTIME_CHANGED", "PlayerFrame.lua"),
("SHOW_COMPARE_TOOLTIP", "PaperDollFrame.lua"),
("SYSMSG", "UIErrorsFrame.lua"),
("UNIT_DEFENSE", "PetPaperDollFrame.lua"),
@@ -2176,3 +2176,84 @@ fn the_unit_frames_publish_every_name_the_reference_declares() {
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The reported symptom, on the stock file that has it: the target's leader crown went stale.**
///
/// `PLAYER_FLAGS_CHANGED` was one of the events a migrated window registered and nothing here
/// produced (decision 2078). `TargetFrame.lua:88-95` is its only 1.12 consumer and it re-runs the
/// **party-leader icon** — *not* an AFK/DND badge, which 1.12 has nowhere on a unit frame and has
/// no `UnitIsAFK`/`UnitIsDND` binding to draw from. With no producer, `TargetFrame_Update` was the
/// crown's only writer, so it moved on a re-target and at no other moment: leadership passing to
/// the player you were already holding did nothing on screen.
///
/// This drives the real stock file, so it is the symptom itself and not a proxy for it.
#[test]
fn the_target_leader_crown_follows_player_flags_changed() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let mate = |leader: bool| {
Some(UnitState {
exists: true,
has_object: true,
name: Some("Groupmate".into()),
health: 100,
max_health: 100,
level: 30,
power_type: 0,
power: 50,
max_power: 50,
// `UnitIsPartyLeader`'s descriptor leg — PLAYER_FLAGS bit 0x1 — and the raw dword the
// event fires on, moved together the way the wire moves them.
group_leader: leader,
player_flags: u32::from(leader),
// **A NON-ZERO guid, or this test cannot fail.** `UnitIsPartyLeader` is two legs ORed
// and the second is a raw compare against the group's leader guid, with no zero guard
// — verified 1.12 behaviour, which is why `UnitIsPartyLeader(nil)` answers 1 while
// solo (`unit::bindings`). A default `UnitState` carries `guid: 0`, which equals the
// zeroed leader of an empty group, so the crown is up from the first frame and every
// assertion below passes for the wrong reason. Naming a guid puts the descriptor leg
// in sole charge, which is the leg this event exists to refresh.
guid: 0x4000_0000_0000_0009,
..UnitState::default()
})
};
let crown =
|s: &UiScript| -> bool { s.eval::<bool>("return TargetLeaderIcon:IsShown()").unwrap() };
// Target an ordinary group member: no crown.
s.set_unit("target", mate(false));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(!crown(&s), "not the leader: no crown");
// **Leadership passes to them while you hold the target.** This is the whole report.
s.set_unit("target", mate(true));
// The control FIRST: the same event for a different token must change nothing, because the
// stock handler is gated on `arg1 == "target"`. A producer that fired argless — or that fired
// the wrong token — would light the crown here and the assertion below would prove nothing.
s.fire_event(
"PLAYER_FLAGS_CHANGED",
vec![ScriptValue::Str("player".into())],
);
assert!(
!crown(&s),
"arg1 = \"player\" is not this frame's unit — the token gate is real"
);
s.fire_event(
"PLAYER_FLAGS_CHANGED",
vec![ScriptValue::Str("target".into())],
);
assert!(crown(&s), "the crown appears without a re-target");
// And back down — the reference fires on the XOR-diff, so the clear edge is the same event.
s.set_unit("target", mate(false));
s.fire_event(
"PLAYER_FLAGS_CHANGED",
vec![ScriptValue::Str("target".into())],
);
assert!(!crown(&s), "leadership leaves and the crown goes with it");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+303 -18
View File
@@ -128,11 +128,14 @@ struct UnitFeedMemo {
/// The last `(PLAYER_XP, PLAYER_NEXT_LEVEL_XP)` pair pushed, for the `PLAYER_XP_UPDATE` trigger —
/// the XP bar's feed is a player-global (like coinage), not a per-unit-token field.
last_xp: Option<(u32, u32)>,
/// The last `(restState, restPool, PLAYER_FLAGS)` triple pushed, for the `UPDATE_EXHAUSTION`
/// and `PLAYER_UPDATE_RESTING` triggers — player-globals like the XP pair (decisions
/// 1082/1087). The whole flags dword, not just the resting bit: the client's `0x5ee990`
/// fires `PLAYER_UPDATE_RESTING` on any PLAYER_FLAGS delta. Pushed as one snapshot so
/// The last `(restState, restPool, PLAYER_FLAGS)` triple pushed, for the `UPDATE_EXHAUSTION`,
/// `PLAYER_UPDATE_RESTING` and `PLAYTIME_CHANGED` triggers — player-globals like the XP pair
/// (decisions 1082/1087/2078). Pushed as one snapshot so
/// `GetRestState`/`GetXPExhaustion`/`IsResting` never read it half-updated.
///
/// The whole flags dword is kept because the two flag events read **different bits of it**
/// (`0x20` and `0x1000|0x2000`), not because either fires on the word as a whole — 2078
/// corrected that, and the feed XORs against this to find which arm moved.
last_rest: Option<(u8, u32, u32)>,
/// Our avatar's last-seen `UNIT_FIELD_LEVEL`, for the `PLAYER_LEVEL_UP` trigger (decision
/// 1094). `None` until first seen — the first sighting is the login descriptor, not a ding.
@@ -924,6 +927,11 @@ pub(crate) fn snapshot(
// TYPEMASK_PLAYER restriction achieves there: a non-player has no PLAYER block at all, so
// the field simply reads absent.
group_leader: store.0.player_is_group_leader(),
// The raw `PLAYER_FLAGS` dword `PLAYER_FLAGS_CHANGED` fires on (decision 2078) — the
// `flags`/`UNIT_FLAGS` pattern one field over. Absent on a creature, which reads 0 and so
// can never produce a delta, exactly as the reference's TYPEID_PLAYER-scoped watcher
// cannot be registered against one.
player_flags: store.0.player_flags(),
reaction,
race: race.map(|(n, _)| n.to_string()),
race_file: race.map(|(_, f)| f.to_string()),
@@ -1255,6 +1263,53 @@ pub(crate) fn fire_transitions(
if prev.is_some() && changed(|u| u64::from(u.flags)) {
script.fire_event("UNIT_FLAGS", vec![tok()]);
}
// `PLAYER_FLAGS_CHANGED` (id 407) — the `UNIT_FLAGS` arm above, one descriptor field over, and
// until decision 2078 one of the events a migrated stock window registered and nothing here
// produced. Byte-read out of `WoW.exe` at `0x5eea27`-`0x5eea3d`:
//
// ```
// 5eea27 8b 56 08 8b 02 89 45 f0 8b 4a 04 89 4d f4 ; [ebp-0x10] = this player's own GUID
// 5eea35 ba 97 01 00 00 ; edx = 0x197 = 407
// 5eea3a 8d 4d f0
// 5eea3d e8 0e 74 f2 ff ; call 0x515e50 — the token fan-out
// ```
//
// Three properties, each load-bearing and each verified rather than assumed:
//
// 1. **Unguarded.** No bit test stands between the XOR-diff at `0x5ee9b8` and this fire, so
// *any* `PLAYER_FLAGS` bit moving announces itself — not just the ones this struct decodes.
// That is why the trigger is the raw dword (`UnitState::player_flags`) and not a bool.
// 2. **Above the local-GUID gate** at `0x5eea93` (the ghost/resting/PvP/play-time arms below it
// are self-only; this one is not) — and the trampoline `0x5e2850` resolves the changed
// object by GUID under TYPEMASK_PLAYER, "any player, not the local one" (wow-re
// `object-layer/ledger.tsv`). So a *stranger's* flags fire it, which is the whole reason the
// stock target frame can listen.
// 3. **Once per unit token naming that GUID, `arg1` = the token, no `arg2`** — `0x515e50` walks
// `0x515c50`'s token array and calls `0x703f50(id, "%s", token)` per entry (wow-re
// `ui/scratch/unit-field-event-bridge.md` §2.2). That is exactly this function's own shape,
// which is why the arm belongs here and not beside the self-only feeds below.
//
// **"Fires for any player" is not "reaches Lua", and the difference is free here.**
// `0x515e63 test eax,eax / 0x515e6a jle 0x515e8a` skips the fan-out loop entirely on a zero
// token count, so a remote player who is nobody's target, mouseover, party or raid member
// announces **nothing** to the VM — the handler still runs, and its helm/cloak arms above the
// gate still repaint them, but no event is signalled. This feed gets that for nothing by
// construction: it is only ever called *per token*, so a tokenless player is never reached
// (wow-re `object-layer/scratch/player-flags-delta-arms.md`, the 2078 correction round).
//
// `prev.is_some()` for the same reason `UNIT_FLAGS` carries it: this is a mirror-diff watcher,
// and a unit's first snapshot is its CREATE, which runs no notify pass (1098 §4).
//
// **The sole 1.12 consumer is the target frame's PARTY-LEADER icon, not an AFK/DND badge** —
// `TargetFrame.lua:88-95` re-runs the `UnitIsPartyLeader("target")` show/hide, and bit `0x1` is
// that predicate's descriptor leg. Nothing in 1.12 FrameXML draws an AFK or DND badge on a unit
// frame, and build 5875 has no `UnitIsAFK`/`UnitIsDND` binding at all (wow-re
// `ui/scratch/unit-predicate-return-shape.md` §5, a zero-hit whole-image byte census); the
// `<AFK>`/`<DND>` the era shows are the chat line's `arg6` flag. This comment is here because
// the gap list said "the AFK/DND badge" for months and sent the first look at the wrong window.
if prev.is_some() && changed(|u| u64::from(u.player_flags)) {
script.fire_event("PLAYER_FLAGS_CHANGED", vec![tok()]);
}
// The POWER pair is named per resource in 1.12, not once with the token as arg2: the reference's
// `UnitFrameManaBar_Initialize` registers `UNIT_MANA`/`UNIT_RAGE`/`UNIT_FOCUS`/`UNIT_ENERGY`/
// `UNIT_HAPPINESS` and the five `UNIT_MAX*` twins (`UnitFrame.lua:190-199`), and nothing in
@@ -1698,16 +1753,40 @@ fn feed_units(
}
}
// The rest feed (decisions 1082/1087): the `PLAYER_BYTES_2` rest-state byte, the
// `PLAYER_REST_STATE_EXPERIENCE` pool and PLAYER_FLAGS, pushed as one snapshot. Two watches,
// the byte-verified grain (wow-re rested-xp-bindings.md §5): `UPDATE_EXHAUSTION` on a
// state-or-pool change (the client installs a watcher on each — `0x5de4e0` on the byte,
// `0x5de4b0` on the pool field), `PLAYER_UPDATE_RESTING` on **any PLAYER_FLAGS delta**
// (`0x5ee990` fires it beside PLAYER_FLAGS_CHANGED without testing which bit moved — the
// resting bit is just its loudest consumer). Runs before the PLAYER_ENTERING_WORLD fire
// below, like the XP push: in the real client the descriptor always lands before that
// event, so the first paint reads real state — the model's byte-2 default (its doc) is the
// backstop, this ordering is the guarantee itself.
// The rest feed (decisions 1082/1087/2078): the `PLAYER_BYTES_2` rest-state byte, the
// `PLAYER_REST_STATE_EXPERIENCE` pool and PLAYER_FLAGS, pushed as one snapshot. Runs before
// the PLAYER_ENTERING_WORLD fire below, like the XP push: in the real client the descriptor
// always lands before that event, so the first paint reads real state — the model's byte-2
// default (its doc) is the backstop, this ordering is the guarantee itself.
//
// **The self-only arms below are each gated on their OWN bits**, which is `0x5ee990`'s actual
// shape and not what this comment said until 2078. Everything from `0x5eea93` down — where the
// handler compares the changed player's GUID against the local one — is self-only, and inside
// that region each arm carries its own `test`:
//
// ```
// 5eead0 f6 45 fc 20 test byte [ebp-4],0x20 ; the RESTING bit CHANGED?
// 5eead4 74 26 je 0x5eeafc ; no -> skip the whole arm
// 5eead6..5eeaed tutorial popup 0x4b5390(0x1d), only if the bit is now SET
// 5eeaf2 b9 95 01 00 00 / e8 … mov ecx,0x195 ; call 0x703e50 <- PLAYER_UPDATE_RESTING
// 5eeafc ...
// 5eeaff f6 c4 02 test ah,0x2 ; bit 0x200, the PvP-desired arm (0652)
// 5eeb65 f6 c4 30 test ah,0x30 ; bits 0x1000|0x2000, the play-time regimes
// 5eeb6f e8 … call 0x703e50 (ecx=0x212) <- PLAYTIME_CHANGED
// ```
//
// `0x5eeaf2` sits INSIDE the `0x20` arm (`0x5eeaf2 < 0x5eeafc`, the `je`'s target), so
// **PLAYER_UPDATE_RESTING fires only on the resting bit's own edge** — not, as wow-re's
// `rested-xp-bindings.md` §5 and its `0x5ee990` ledger row both say, "on every flags change,
// not only the resting bit". That claim is what this feed was built against, and it made every
// helm toggle, every leadership pass and every AFK flip announce a resting change to the UI.
// The note's "argless unconditionally" is true only of the *direction*: the inner
// `0x5eeae4 je` skips the tutorial popup, never the fire, so both edges of the bit fire it.
// A correction round is dispatched into wow-re; the bytes above are read straight out of
// `WoW.exe` (file offset 0x1eead0, PE imagebase 0x400000, .text RVA 0x1000 → raw 0x1000).
//
// `UPDATE_EXHAUSTION` is unchanged and was already right: two separate watchers, `0x5de4e0` on
// the rest-state byte and `0x5de4b0` on the pool field, neither of them this handler.
if let Some((store, _)) = self_q.iter().next() {
let rest = (
store.0.player_rest_state().unwrap_or(0),
@@ -1720,8 +1799,8 @@ fn feed_units(
memo.last_rest = Some(rest);
script.set_rest_state(rest.0, rest.1, rest.2 & PLAYER_FLAGS_RESTING != 0);
// The same descriptor word carries the two play-time bits, and they ride the same
// memo: `0x5ee990` fires PLAYER_FLAGS_CHANGED on ANY PLAYER_FLAGS delta without
// testing which bit moved, so one snapshot is the faithful granularity for all three.
// memo — the PUSH is one snapshot because the wire delivers one dword; only the
// EVENTS below split by bit.
script.set_play_time(
rest.2 & PLAYER_FLAGS_PARTIAL_PLAY_TIME != 0,
rest.2 & PLAYER_FLAGS_NO_PLAY_TIME != 0,
@@ -1741,8 +1820,22 @@ fn feed_units(
));
}
}
if prev.map(|p| p.2) != Some(rest.2) {
script.fire_event("PLAYER_UPDATE_RESTING", vec![]);
// The two self-only flag events, each on its own bits' edge (`0x5eead0` and
// `0x5eeb65` above). `prev` None is the login descriptor and stays silent for both,
// the same fresh-CREATE reasoning the rest message carries — a mirror-diff watcher
// has nothing to diff against on a create.
if let Some(p) = prev {
let moved = p.2 ^ rest.2;
if moved & PLAYER_FLAGS_RESTING != 0 {
script.fire_event("PLAYER_UPDATE_RESTING", vec![]);
}
// PLAYTIME_CHANGED (id 530) — `0x5eeb65 test ah,0x30`, argless, self-only. Stock
// `PlayerFrame.lua:23` registers it and `PlayerFrame_UpdatePlaytime` reads
// `PartialPlayTime()`/`NoPlayTime()`, both of which we already feed off this very
// snapshot; the event was the one missing half. Decision 2078.
if moved & (PLAYER_FLAGS_PARTIAL_PLAY_TIME | PLAYER_FLAGS_NO_PLAY_TIME) != 0 {
script.fire_event("PLAYTIME_CHANGED", vec![]);
}
}
}
}
@@ -2213,6 +2306,198 @@ mod tests {
assert!(fired(base.clone(), base.clone()).is_empty());
}
/// **`PLAYER_FLAGS_CHANGED` — the event a migrated stock window listened for and nothing
/// fired** (decision 2078), pinned on the three properties that make it what it is.
///
/// The gap was invisible from both ends, which is 1819's shape exactly: `TargetFrame.lua`
/// registered the name and this feed never spoke it, so the target frame's party-leader icon
/// only ever refreshed on a re-target. The census that caught it
/// (`reference_ui::every_event_a_chain_file_registers_has_a_producer`) described it as "the
/// AFK/DND badge"; the handler at `TargetFrame.lua:88-95` is the LEADER icon, and 1.12 has no
/// AFK/DND badge on any unit frame — so the control below is a bit no `UnitState` field
/// decodes, proving the trigger is the raw dword and not the two bools next to it.
#[test]
fn player_flags_changed_fires_per_token_on_any_bit_and_never_on_first_sight() {
let fired = |prev: Option<UnitState>, cur: UnitState| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_FLAGS_CHANGED")
f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end)
"#,
)
.unwrap();
fire_transitions(&mut s, "target", prev.as_ref(), &cur);
s.eval::<Vec<String>>("return SEEN").unwrap()
};
let base = UnitState {
exists: true,
has_object: true,
..Default::default()
};
let with = |flags: u32| UnitState {
player_flags: flags,
group_leader: flags & 0x1 != 0,
ghost: flags & 0x10 != 0,
..base.clone()
};
// The 1.12 consumer's own bit: `PLAYER_FLAGS_GROUP_LEADER 0x1`, `UnitIsPartyLeader`'s
// descriptor leg — leadership passing to the player you are targeting.
assert_eq!(
fired(Some(with(0)), with(0x1)),
vec!["PLAYER_FLAGS_CHANGED:target".to_string()],
"arg1 is the unit token, and there is no arg2 (0x515e50 -> 0x703f50(id, \"%s\", token))"
);
// …and back down. The reference tests the XOR-diff, not the new value.
assert_eq!(
fired(Some(with(0x1)), with(0)),
vec!["PLAYER_FLAGS_CHANGED:target".to_string()]
);
// **The control that proves the trigger is the RAW DWORD.** `PLAYER_FLAGS_HIDE_HELM 0x400`
// is a bit no `UnitState` field decodes — the fire at `0x5eea35` carries no bit test, so
// it announces this exactly as loudly as the leader bit. A version of this arm driven off
// `group_leader`/`ghost` passes every assertion above and fails here.
assert_eq!(
fired(Some(with(0)), with(0x400)),
vec!["PLAYER_FLAGS_CHANGED:target".to_string()],
"an undecoded bit still fires it — the handler tests no bit at all"
);
// First sight is the CREATE, and a CREATE runs no notify pass (1098 §4) — the same
// posture `UNIT_FLAGS` holds one field over.
assert!(
fired(None, with(0x1)).is_empty(),
"a unit's first snapshot is its create, not a transition"
);
// The control that stops all of the above passing by firing always.
assert!(fired(Some(with(0x1)), with(0x1)).is_empty());
}
/// **The two SELF-ONLY arms of the same handler, each on its own bits** — the half that was
/// over-firing, and the half that was missing (decision 2078).
///
/// `0x5ee990`'s local-GUID gate at `0x5eea93` divides it: `PLAYER_FLAGS_CHANGED` above (any
/// player), and below it three arms that each carry their own `test` against the XOR-diff.
/// benilla fired `PLAYER_UPDATE_RESTING` on *any* `PLAYER_FLAGS` delta, because wow-re's
/// `rested-xp-bindings.md` §5 says it does; `0x5eead0 f6 45 fc 20 / 74 26` says otherwise —
/// the `je`'s target is `0x5eeafc` and the fire is at `0x5eeaf2`, inside the arm.
#[test]
fn the_self_flag_events_each_fire_on_their_own_bits() {
use bevy::ecs::system::RunSystemOnce;
const FIELD_PLAYER_FLAGS: u16 = 190;
let mut app = App::new();
app.init_resource::<Selection>()
.init_resource::<UnitFeedState>()
.init_resource::<NameCache>()
.init_resource::<Reputations>()
.init_resource::<crate::ui_party::GroupState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::ui_guild::GuildState>();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
let script = UiScript::new().unwrap();
script
.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_UPDATE_RESTING")
f:RegisterEvent("PLAYTIME_CHANGED")
f:RegisterEvent("UPDATE_EXHAUSTION")
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
"#,
)
.unwrap();
app.insert_non_send_resource(script);
let me = app
.world_mut()
.spawn((
SelfPlayer,
Guid(0x77),
ObjectStore(
benilla_protocol::ObjectFields::from_pairs(&[])
.into_created(benilla_protocol::messages::ObjectType::Player),
),
))
.id();
// Set PLAYER_FLAGS, run the feed, read back what fired.
let step = |app: &mut App, flags: u32| -> Vec<String> {
app.world_mut()
.entity_mut(me)
.get_mut::<ObjectStore>()
.unwrap()
.0
.merge(benilla_protocol::ObjectFields::from_pairs(&[(
FIELD_PLAYER_FLAGS,
flags,
)]));
app.world_mut().run_system_once(feed_units).unwrap();
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.resolve();
let out = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
out
};
// Login: the first rest snapshot seeds the memo and says nothing about the flags word.
// (`UPDATE_EXHAUSTION` is a different watcher — `0x5de4e0`/`0x5de4b0`, not this handler —
// and its first-sight fire is 1087's settled posture, so it is filtered, not asserted on.)
let flag_events = |v: Vec<String>| -> Vec<String> {
v.into_iter().filter(|e| e != "UPDATE_EXHAUSTION").collect()
};
assert!(
flag_events(step(&mut app, 0)).is_empty(),
"the login descriptor is a create: structurally silent (1098 §4)"
);
// **The regression.** `PLAYER_FLAGS_HIDE_HELM 0x400` moves and the resting bit does not —
// before 2078 this announced a resting change to every listener in the UI.
assert!(
flag_events(step(&mut app, 0x400)).is_empty(),
"a non-resting, non-playtime bit fires neither self event"
);
// The resting bit's own edge, both ways: `0x5eead0 test byte [ebp-4],0x20`.
assert_eq!(
flag_events(step(&mut app, 0x400 | PLAYER_FLAGS_RESTING)),
vec!["PLAYER_UPDATE_RESTING".to_string()]
);
assert_eq!(
flag_events(step(&mut app, 0x400)),
vec!["PLAYER_UPDATE_RESTING".to_string()],
"the fire is on the XOR-diff, so the clear edge fires it too"
);
// `PLAYTIME_CHANGED` — `0x5eeb65 test ah,0x30`, the two play-time regimes together.
assert_eq!(
flag_events(step(&mut app, 0x400 | PLAYER_FLAGS_PARTIAL_PLAY_TIME)),
vec!["PLAYTIME_CHANGED".to_string()]
);
assert_eq!(
flag_events(step(
&mut app,
0x400 | PLAYER_FLAGS_PARTIAL_PLAY_TIME | PLAYER_FLAGS_NO_PLAY_TIME
)),
vec!["PLAYTIME_CHANGED".to_string()],
"the second regime bit is the same arm, not a second event"
);
// Both arms at once, from one dword: the handler runs them in order and neither masks the
// other.
assert_eq!(
flag_events(step(&mut app, 0x400 | PLAYER_FLAGS_RESTING)),
vec![
"PLAYER_UPDATE_RESTING".to_string(),
"PLAYTIME_CHANGED".to_string()
]
);
}
/// **Report B304 — Quiver's range indicator read Dead Zone on a far target.**
///
/// Its Dead Zone rung is `IsActionInRange(AutoShot) ~= 1` **and**
+64 -20
View File
@@ -28,26 +28,43 @@
//! a shapeshift) is the teardown, exactly as `0x6c6970` frees the object with the model reference
//! it holds.
//!
//! ## The two named deviations
//! ## The light term — ambient only, and no `N·L` half
//!
//! - **The trail is LIT in the reference and unlit here.** The callback writes EGxRs `0x0e = 1`
//! (`0x6c6847`; `GL_LIGHTING` / `D3DRS_LIGHTING = TRUE`) — but its vertex format carries **no
//! normal** (format 7 is position + colour, stride 16), and what a fixed-function pipeline
//! produces from a lit vertex with no normal is not settled by the state bit alone. wow-re's own
//! round left the neighbouring question — whether the device even disables the texture unit for
//! a declaration with no texcoord — `DEFERRED:` on `gx`'s board for the same unwalked functions
//! (`0x594210`, `0x5a1b20`). Modelling a light term on a guess at the normal would be inventing
//! the look; the authored colour burns as it is until the bytes say what the light does to it.
//! Dispatched.
//! - **No combat-over-combat fast path.** `0x5fe43c` skips the latch read entirely when the unit
//! is *already* playing a combat animation and requests another one — it re-times the clip
//! instead of restarting it (`0x5fe48b ret`, before `0x5fe48e`), so the arm is **held**, not
//! lost, and fires on the next play that is not that shape. The combat set is `0x5fcc10`'s
//! bytemap `{10, 16–24, 30, 36, 57, 58, 59, 85–88, 95, 117, 118}`. benilla's driver is batched
//! and has no single "currently playing id" across its base and masked slots, so this is not
//! modelled: we fire on the frame's edge, which makes a trail start *slightly earlier* than the
//! reference in the one case where a combat one-shot lands mid-combat-animation. Over-firing,
//! never under-firing, and never a trail the reference would not eventually draw.
//! The callback turns lighting **on** (EGxRs `0x0e = 1` at `0x6c6847`) over a vertex format that
//! carries **no normal** (format 7 is position + colour, stride 16). That combination is not a
//! contradiction and it is not a no-op — it selects a specific, reduced term, derived at the bytes
//! in wow-re `format7-lighting-term.md`:
//!
//! ```text
//! out = (Σ enabled lights' Ambient) × authoredColour + inherited material Emissive
//! ```
//!
//! - The vertex colour **is** the material: `0x5a1e30` sets both `DIFFUSEMATERIALSOURCE` and
//! `AMBIENTMATERIALSOURCE` to `D3DMCS_COLOR1` off the format's diffuse bit, which format 7 has.
//! - The normal is **explicitly disabled**, not stale: `0x592a60` replaces the format mask rather
//! than OR-ing it, and `0x59c100` issues `glDisableClientState(GL_NORMAL_ARRAY)` when the bit is
//! clear (D3D's format-7 descriptor carries `−1` in the normal column). So there is no `N·L` to
//! compute and the **diffuse half contributes nothing** — which is why this does not simply take
//! [`benilla_world::particles::buffer::EffectLighting::Scene`], whose term is
//! `clamp(ambient + diffuse·max(N·L, 0))` against the world up axis. Applying that here would
//! add a sun term the reference does not have.
//! - The global `D3DRS_AMBIENT` (EGxRs id 6) has **zero writers image-wide** and defaults to 0, so
//! the whole ambient contribution comes from the M2 lighting collector's own enabled lights —
//! the same lights our lit emitters take their `ambient` from.
//!
//! So the trail **darkens with the scene** rather than burning at its authored colour. Two named
//! approximations, both the same class as [`crate::fishing_line`]'s:
//!
//! - we sample the scene ambient ([`benilla_world::lighting::WowLighting`]) rather than summing a
//! per-model light collector, so an interior trail takes the exterior ambient;
//! - the **emissive** term is not modelled. It is inherited material state whose value that round
//! did not pin, and `EMISSIVEMATERIALSOURCE` is set nowhere, so it is a constant we would be
//! inventing rather than reproducing.
//!
//! The fold happens on the CPU, into the vertex colours, and the draw declares
//! [`benilla_world::particles::buffer::EffectLighting::None`] — the ambient is a per-draw constant,
//! and per-draw constants belong in the vertex stream on a lane whose whole design is one shared
//! buffer of them (the effect lane's own rule; it is what the `Committed` variant exists for).
//!
//! ## What is deliberately NOT here
//!
@@ -321,6 +338,18 @@ fn fade_step(dt_ms: u32, alpha: u8) -> u32 {
/// the unit plays next, and because `0x5fe2f0` is the image's **single** animation entry point
/// that is the charge's own run, not the swing at the end of it.
///
/// **The combat fast path is honoured, and benilla got it for free.** `0x5fe43c` returns before
/// the latch read when the unit is *already* playing a combat animation and requests another one —
/// it re-times the current clip instead of restarting it — so the arm survives that call. Missing
/// that would not have invented trails out of nothing, but it would have drawn trails from arms
/// the reference **supersedes before firing** (`0x60d835` is a plain `mov` into a one-slot field,
/// so a second proc overwrites the first), with the superseded colours and durations, on 23 of the
/// 34 type-8 kits. We do not fire there because the driver's own request loop already models the
/// fast path (decision 0406, `select::is_combat_anim` = `0x5fcc10`'s byte-decoded set): it
/// `continue`s without playing, so neither `base_played` nor `masked_played` is raised and the
/// edge stays low. Pinned by
/// [`the fast path test`](crate::creature_anim::driver::tests).
///
/// There is **no per-frame recompute** in the reference (`0x5fd8b0` has one caller;
/// `0x5fd9e0`'s 38 sites are all event-driven), so an arm on a unit that then changes nothing sits
/// armed indefinitely and the trail starts late — at whatever event finally plays something. The
@@ -370,6 +399,7 @@ fn draw_weapon_trails(
time: Res<Time>,
mut draw: WorldEffectDraw,
white: Res<TrailWhite>,
lighting: Option<Res<benilla_world::lighting::WowLighting>>,
world_cam: Query<Entity, With<WorldCamera>>,
mut trails: Query<(
Entity,
@@ -381,6 +411,15 @@ fn draw_weapon_trails(
let Ok(cam) = world_cam.single() else {
return;
};
// The ambient half of the fixed-function term, folded per draw (see the module doc). Absent
// before the first lighting resolve — burn at the authored colour rather than at black.
let ambient = lighting.as_deref().map_or([1.0; 3], |l| {
[
l.ambient[0].clamp(0.0, 1.0),
l.ambient[1].clamp(0.0, 1.0),
l.ambient[2].clamp(0.0, 1.0),
]
});
let now_ms = time.elapsed().as_millis() as u32;
for (entity, mut trail, prop, vis) in &mut trails {
let bottom = prop.transform_point(trail.bottom);
@@ -434,7 +473,12 @@ fn draw_weapon_trails(
verts.push(EffectVertex {
pos: pos.to_array(),
uv: [0.0, 0.0],
color: [rgb[0], rgb[1], rgb[2], a],
color: [
rgb[0] * ambient[0],
rgb[1] * ambient[1],
rgb[2] * ambient[2],
a,
],
});
}
}
@@ -160,9 +160,19 @@ pub fn run(chain: &mut Chain) -> Result<()> {
let anim = kit.and_then(|k| k.anim_id);
let [r, g, b] = trail.rgb();
let spells = reach.get(kit_id);
// `CharParamOne` is printed even though the type-8 arm never reads it (`0x60d80a`'s three
// `_ftol`s take Zero/Two/Three only). wow-re's note states every shipped row carries
// `20.0` there; a census that hides the column cannot catch that being wrong, and it is —
// Sinister Strike's kit 399 carries 15.0.
let unread = kit
.and_then(|k| {
k.char_procs()
.find(|p| p.ty == char_proc_type::WEAPON_TRAIL)
})
.map_or(0.0, |p| p.params[1]);
println!(
" kit {kit_id:<5} anim {:<6} slots {slots} #{r:02x}{g:02x}{b:02x} a{:<4} {:>6} ms \
{} spell(s)",
one={unread:<5} {} spell(s)",
anim.map_or("-".to_string(), |a| a.to_string()),
trail.alpha(),
trail.duration_ms,
+109
View File
@@ -0,0 +1,109 @@
//! `GameTips.dbc` — the loading-screen "tip of the day" (decision 2077).
//!
//! The 74 one-line hints the reference draws over a loading screen, each already carrying its own
//! `|cffffd100Tip:|r` prefix and a trailing `\r\n` or two in the data. Nothing in FrameXML reads
//! them — `showGameTips`' only 1.12 mention there is
//! `UIOptionsFrameCheckButtons["SHOW_TIPOFTHEDAY_TEXT"] = { index = 44, cvar = "showGameTips" }`
//! and the tooltip string "Uncheck this to hide the tip of the day in the load screens", so the
//! whole feature is engine-side.
//!
//! **The table is the reference's own growable array** at `[0xc0dcd0]` (data) / `[0xc0dcd4]`
//! (count), stride `0x28` = this record size, filled by the DBC loader `0x545f90` from the run of
//! per-table loaders at `0x5404bb`. `CGlueMgr::EnterWorld` (`0x46b500`) walks the count at
//! `0x46b684` and stores the chosen slot back into the `gameTip` CVar.
//!
//! Layout — the real 5875 file (10883 bytes, **74** records × 10 × u32, 7903-byte string block):
//! `ID(0), Text[8](1..8), TextMask(9)` — the ordinary 1.12 localized-string shape, and only the
//! first locale slot is populated in this chain. **Record order is not id order** (the file opens
//! on id 396 and closes on 412, ids 201..412 with no duplicates), which is why the index the
//! reference persists is a *record* index into the file as loaded, never an id.
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{parse, str_at};
const GAME_TIPS: &str = "DBFilesClient\\GameTips.dbc";
/// The tips, in the file's own record order — the order the reference's array holds them in, and
/// so the order its persisted index counts through.
#[derive(Clone, Debug, Default)]
pub struct GameTipsCatalog {
tips: Vec<String>,
}
impl GameTipsCatalog {
/// A catalog over rows given directly — for a test that must run without an install.
pub fn from_tips(tips: Vec<String>) -> Self {
GameTipsCatalog { tips }
}
/// The tip at a **record index**, or `None` when the index is past the table (an index read
/// back from a config file written by a different chain).
pub fn get(&self, index: usize) -> Option<&str> {
self.tips.get(index).map(String::as_str)
}
pub fn len(&self) -> usize {
self.tips.len()
}
pub fn is_empty(&self) -> bool {
self.tips.is_empty()
}
}
fn schema() -> Schema {
let mut s = Schema::new("GameTips");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new_array("Text", FieldType::String, 8));
s.add_field(SchemaField::new("TextMask", FieldType::UInt32));
s.set_key_field("ID");
s
}
pub fn load_game_tips(chain: &mut Chain) -> Result<GameTipsCatalog> {
let bytes = chain.read_file(GAME_TIPS).context("reading GameTips.dbc")?;
let rs = parse(&bytes, schema(), "GameTips")?;
let mut tips = Vec::with_capacity(rs.records().len());
for r in rs.records() {
// A row whose localized slot is empty is dropped rather than kept as a blank: the
// reference's array is what the index counts through, and a blank slot would be a
// loading screen with no tip on it for no reason a player could act on.
match str_at(&rs, r, 1) {
Some(text) if !text.trim().is_empty() => tips.push(text),
_ => {}
}
}
Ok(GameTipsCatalog { tips })
}
#[cfg(test)]
mod tests {
use super::*;
/// The shipped table: 74 tips, in file order, each already carrying the reference's own
/// colour prefix. Skips without client data.
#[test]
fn the_real_table_is_seventy_four_tips_in_file_order() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_game_tips(&mut chain).expect("load GameTips");
assert_eq!(cat.len(), 74, "the 5875 enGB chain ships 74 tips");
assert!(
cat.get(0).unwrap().starts_with("|cffffd100"),
"the colour prefix is authored INTO the data, not added by the renderer: {:?}",
cat.get(0)
);
assert!(
cat.get(0).unwrap().contains("mini-map"),
"record order, not id order — record 0 is id 396: {:?}",
cat.get(0)
);
assert!(
cat.get(74).is_none(),
"one past the end is None, not a panic"
);
}
}
+4
View File
@@ -301,6 +301,10 @@ pub use chat_channels::{
};
mod server_messages;
pub use server_messages::{load_server_messages_catalog, ServerMessagesCatalog};
mod game_tips;
pub use game_tips::{load_game_tips, GameTipsCatalog};
mod text_filter_lists;
pub use text_filter_lists::{load_chat_profanity, load_spam_messages, FilterPattern};
mod race_sound;
pub use race_sound::{load_exploration_sound_catalog, ExplorationSoundCatalog};
mod zone_map;
@@ -0,0 +1,120 @@
//! `ChatProfanity.dbc` + `SpamMessages.dbc` — the two shipped pattern lists behind 1.12's
//! `profanityFilter` and `spamFilter` (decision 2077; wow-re `system/ui/scratch/text-filter-law.md`).
//!
//! **They are regular expressions, not word lists.** The reference compiles every row with PCRE
//! (`0x71fba0` → `pcre_compile 0x720250`, options `0x2801` = CASELESS | UTF8 | NO_UTF8_CHECK) at
//! startup — one linear pass, `0x402b7f → 0x6c91a0`, no reload path — and a row that fails to
//! compile is **skipped** rather than stored, so the live count is the number that compiled. On the
//! shipped data none fails.
//!
//! This module only *reads* the rows. Translating a pattern into something a modern engine runs, and
//! the mask/predicate laws over them, are the client's (`benilla_app::text_filter`).
//!
//! **The archive matters, and it is the easy thing to get wrong.** Both files must resolve through
//! the ordinary priority walk, not out of `dbc.MPQ`: `ChatProfanity` is **2289** rows in `patch.MPQ`
//! against 1512 in `dbc.MPQ`, and `dbc.MPQ` carries no `SpamMessages` at all. Neither filename holds
//! a locale (`"DBFilesClient\ChatProfanity.dbc"` `0x858348`, `"DBFilesClient\SpamMessages.dbc"`
//! `0x859a84`, both literal, neither with a `%s`), so a locale build overrides them one layer down,
//! at the archive — which is exactly what [`crate::Chain`] already does.
//!
//! Layout: 2 × u32 — `{ID(0), Pattern(1)}`, and the pattern is a *plain* string column, not the
//! eight-slot localized shape (the list is multi-language in one table: of ChatProfanity's 2289
//! rows 850 are ASCII, the rest carry high bytes — Korean, Chinese, French).
//!
//! The two lists are **disjoint in practice**: the spam list is 28 gold-seller URL patterns, each
//! letter separated by `\s*` to defeat spacing.
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{parse, str_at, u32_at};
const CHAT_PROFANITY: &str = "DBFilesClient\\ChatProfanity.dbc";
const SPAM_MESSAGES: &str = "DBFilesClient\\SpamMessages.dbc";
/// One row: the DBC id (for diagnostics — a failed compile names it, as the reference's own
/// `"…filter expression: \"%s\" (record ID %d)"` does) and its raw PCRE pattern.
#[derive(Clone, Debug)]
pub struct FilterPattern {
pub id: u32,
pub pattern: String,
}
fn schema(name: &str) -> Schema {
let mut s = Schema::new(name);
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new("Pattern", FieldType::String));
s.set_key_field("ID");
s
}
fn load(chain: &mut Chain, path: &str, name: &str) -> Result<Vec<FilterPattern>> {
let bytes = chain
.read_file(path)
.with_context(|| format!("reading {name}.dbc"))?;
let rs = parse(&bytes, schema(name), name)?;
let mut out = Vec::with_capacity(rs.records().len());
for r in rs.records() {
// **File order is the list order**, and the mask law depends on it: patterns apply in list
// order, not text order, so the row a sentence's *later* word matches can take the earlier
// mask characters. Never sort.
let (Some(id), Some(pattern)) = (u32_at(r, 0), str_at(&rs, r, 1)) else {
continue;
};
if pattern.is_empty() {
continue;
}
out.push(FilterPattern { id, pattern });
}
Ok(out)
}
/// `ChatProfanity.dbc` — the masker's list, in file order.
pub fn load_chat_profanity(chain: &mut Chain) -> Result<Vec<FilterPattern>> {
load(chain, CHAT_PROFANITY, "ChatProfanity")
}
/// `SpamMessages.dbc` — the spam predicate's shipped list, in file order.
///
/// The reference scans this list **then** a second, server-pushed one (SMSG `0x332`, handler
/// `0x49e6e0`), which starts empty and stays empty unless the server sends it. vmangos never does.
pub fn load_spam_messages(chain: &mut Chain) -> Result<Vec<FilterPattern>> {
load(chain, SPAM_MESSAGES, "SpamMessages")
}
#[cfg(test)]
mod tests {
use super::*;
/// The shipped lists, measured — the row counts wow-re's oracle run compiled the binary's own
/// PCRE over, and the proof that the priority walk picked the right archive (the `dbc.MPQ`
/// copies are 1512 rows and *absent* respectively). Skips without client data.
#[test]
fn the_lists_come_off_the_priority_walk_at_their_patched_sizes() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let profanity = load_chat_profanity(&mut chain).expect("load ChatProfanity");
assert_eq!(
profanity.len(),
2289,
"2289 is patch.MPQ's copy; 1512 would mean we read dbc.MPQ"
);
assert!(
profanity.iter().any(|p| p.pattern == r"\<twat\>"),
"the ASCII half uses the \\< \\> word-boundary anchors"
);
assert!(
profanity.iter().any(|p| !p.pattern.is_ascii()),
"the list is multi-language in one table"
);
let spam = load_spam_messages(&mut chain).expect("load SpamMessages");
assert_eq!(spam.len(), 28, "dbc.MPQ has no SpamMessages at all");
assert!(
spam.iter().all(|p| p.pattern.contains(r"\s*")),
"every shipped spam row spaces its letters to defeat spacing"
);
}
}
+15 -1
View File
@@ -17,11 +17,19 @@ use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8};
// ChatMsg values (VERIFIED vmangos `SharedDefines.h:1191-1301`, the 5875 band — every
// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`-or-earlier guard is compile-time true for build
// 5875). vmangos's enum runs the full `0x00..=0x5D`; the combat-log/spell/skill/loot/money block
// (0x09, 0x0F-0x13, 0x17-0x50, 0x52-0x56, 0x5B) is **never** `SMSG_MESSAGECHAT` wire in 1.12 — canned
// (0x09, 0x0F-0x13, 0x17-0x50, 0x52-0x56) is **never** `SMSG_MESSAGECHAT` wire in 1.12 — canned
// emotes ride `SMSG_TEXT_EMOTE`, channel feedback rides `SMSG_CHANNEL_NOTIFY`/`_LIST`, and
// skill/loot/money/xp lines are client-composed from their own opcodes (decision 0288) — so those
// values have no named constant here. What's below is every type benilla either receives (as a
// distinct wire shape or a renderable line) or sends.
//
// **`0x5B` was in that list until decision 2077 and it did not belong there.** It is
// `CHAT_MSG_FILTERED`, and it is a real server→client line: 1.12's `ChatFrame.lua` puts it in
// `ChatTypeGroup["SYSTEM"]` (l.128) and renders it at l.1405 as `format(CHAT_FILTERED, arg2)` —
// "Unable to send chat to %s because your message contained reserved words." The client's own chat
// chokepoint carves it out of the spam filter **by name** (`0x49aac9 cmp edi,0x5b`) precisely
// because it is a line that arrives, and one the filter itself produces. vmangos declares the
// constant and never sends it, which is what the wrong claim was really observing.
pub const CHAT_MSG_SAY: u8 = 0x00;
pub const CHAT_MSG_PARTY: u8 = 0x01;
pub const CHAT_MSG_RAID: u8 = 0x02;
@@ -53,6 +61,12 @@ pub const CHAT_MSG_RAID_WARNING: u8 = 0x58;
/// aliased `CHAT_MSG_MONSTER_WHISPER` instead; irrelevant here since 5875 always takes this branch).
pub const CHAT_MSG_RAID_BOSS_WHISPER: u8 = 0x59;
pub const CHAT_MSG_RAID_BOSS_EMOTE: u8 = 0x5A;
/// **The server's "your message was filtered" notice** — not a filtered incoming line. The stock
/// chat frame formats it as `CHAT_FILTERED` over `arg2` (the addressee), so the wire text carries
/// the name rather than a sentence. It is the one chat type the client's own spam filter is
/// *disarmed* for (`0x49aacc`), it is absent from the profanity arm's 14-type table, and it is
/// delivered whatever both CVars say. wow-re `system/ui/scratch/text-filter-law.md`.
pub const CHAT_MSG_FILTERED: u8 = 0x5B;
pub const CHAT_MSG_BATTLEGROUND: u8 = 0x5C;
pub const CHAT_MSG_BATTLEGROUND_LEADER: u8 = 0x5D;
+6 -6
View File
@@ -96,12 +96,12 @@ pub use channel::{channel_notice, ChannelNoticeTail, ChannelNotify};
pub use chat::{
chat_tag, ChatMessage, CHAT_MSG_AFK, CHAT_MSG_BATTLEGROUND, CHAT_MSG_BATTLEGROUND_LEADER,
CHAT_MSG_BG_SYSTEM_ALLIANCE, CHAT_MSG_BG_SYSTEM_HORDE, CHAT_MSG_BG_SYSTEM_NEUTRAL,
CHAT_MSG_CHANNEL, CHAT_MSG_DND, CHAT_MSG_EMOTE, CHAT_MSG_GUILD, CHAT_MSG_IGNORED,
CHAT_MSG_MONSTER_EMOTE, CHAT_MSG_MONSTER_SAY, CHAT_MSG_MONSTER_WHISPER, CHAT_MSG_MONSTER_YELL,
CHAT_MSG_OFFICER, CHAT_MSG_PARTY, CHAT_MSG_RAID, CHAT_MSG_RAID_BOSS_EMOTE,
CHAT_MSG_RAID_BOSS_WHISPER, CHAT_MSG_RAID_LEADER, CHAT_MSG_RAID_WARNING, CHAT_MSG_SAY,
CHAT_MSG_SYSTEM, CHAT_MSG_WHISPER, CHAT_MSG_WHISPER_INFORM, CHAT_MSG_YELL,
MACRO_EXPANDED_TYPES,
CHAT_MSG_CHANNEL, CHAT_MSG_DND, CHAT_MSG_EMOTE, CHAT_MSG_FILTERED, CHAT_MSG_GUILD,
CHAT_MSG_IGNORED, CHAT_MSG_MONSTER_EMOTE, CHAT_MSG_MONSTER_SAY, CHAT_MSG_MONSTER_WHISPER,
CHAT_MSG_MONSTER_YELL, CHAT_MSG_OFFICER, CHAT_MSG_PARTY, CHAT_MSG_RAID,
CHAT_MSG_RAID_BOSS_EMOTE, CHAT_MSG_RAID_BOSS_WHISPER, CHAT_MSG_RAID_LEADER,
CHAT_MSG_RAID_WARNING, CHAT_MSG_SAY, CHAT_MSG_SYSTEM, CHAT_MSG_WHISPER,
CHAT_MSG_WHISPER_INFORM, CHAT_MSG_YELL, MACRO_EXPANDED_TYPES,
};
pub use client::{
auth_session, channel_announcements, channel_ban, channel_invite, channel_kick, channel_list,
+15
View File
@@ -202,6 +202,21 @@ pub struct UnitState {
/// `UNIT_FIELD_FLAGS`, raw — what `UNIT_FLAGS` fires on (the app's `fire_transitions`); the
/// three readings above are bits of it.
pub flags: u32,
/// `PLAYER_FLAGS` (descriptor index 190), raw — what **`PLAYER_FLAGS_CHANGED`** fires on, the
/// [`Self::flags`] pattern one field over. `0` on a creature, which has no PLAYER block.
///
/// The reference watches this dword type-wide over TYPEID_PLAYER (`0x468070(ecx=4, edx=8,
/// width 4)` registered at `0x5e25d7`), and its handler `0x5ee990` fires the event from
/// `0x5eea35`-`0x5eea3d` — **unguarded by any bit test, and above the local-player GUID gate
/// at `0x5eea93`** — so *any* bit moving on *any* player announces itself, remote players
/// included. Decision 2078.
///
/// Raw, not a decoded subset, and that is the point: [`Self::group_leader`] (`0x1`) and
/// [`Self::ghost`] (`0x10`) are the only bits this struct decodes, while `0x2`/`0x4` (the
/// chat AFK/DND flags), `0x8` (GM), `0x200` (PvP-desired), `0x400`/`0x800` (hide helm/cloak)
/// and `0x1000`/`0x2000` (the play-time regimes) all move without touching either. Firing off
/// the decoded pair would silently under-announce every one of them.
pub player_flags: u32,
/// The unit's owner — `UNIT_FIELD_SUMMONEDBY`, else its charmer, else its creator; `0` for
/// nobody's. What `UnitPlayerOrPetInParty`/`InRaid` read for the "or pet" half (1958).
pub owner: u64,