compat: publish LOCALIZED_CLASS_NAMES_MALE/_FEMALE, the table tInvert exists to flip

Half this pair was already here. `ClassInfo::class_file`'s own doc says the token is what
FrameXML keys `RAID_CLASS_COLORS` and `LOCALIZED_CLASS_NAMES_MALE` on, and
`compat/classic_api/lua_ext.rs`'s `tInvert` names pfUI's `pfUI.lua:227` as the reason it exists —
the line that inverts exactly this table. The table itself was never published, so that line
inverted `nil`.

Measured against the live chain: `pfUI.env.L["class"]` holds **0 entries**, and
`L["class"]["Warrior"]` is nil. Every lookup through it then finds no class and skips, silently,
because the consumer tests `if class then` — so the guild roster lost its name colours and class
icons (`modules/socialmod.lua:18`), the friends list lost the same, and `modules/panel.lua:359`
fell back to white. Chat stayed coloured throughout, which is what made it look like a pfUI
setting rather than a missing global.

Published from the class table the app already feeds for `C_CreatureInfo.GetClassInfo`, at that
same feed, so there is one source for the names and no second reader of the DBC. Both tables
carry the same string: 1.12's `ChrClasses.dbc` has one name column, so the gendered pair later
clients ship cannot be derived from this data — a copy is what it honestly supports, and omitting
`_FEMALE` would hand a caller nil where its sibling answers. A run that never feeds the table
leaves both absent rather than empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-16 18:45:25 -06:00
co-authored by Claude Opus 5
parent 751cac062b
commit 0a90795555
4 changed files with 89 additions and 0 deletions
@@ -15,6 +15,48 @@ impl super::UiScript {
/// with no install never calls it and the table stays empty, which `GetClassInfo` answers as
/// "no such class" rather than as an error.
pub fn set_class_info(&mut self, classes: BTreeMap<u32, ClassInfo>) {
self.publish_localized_class_names(&classes);
self.model_mut().class_info = classes;
}
/// Publish `LOCALIZED_CLASS_NAMES_MALE`/`_FEMALE`, the later client's token → display-name
/// tables, from the same two `ChrClasses` columns. Not 1.12 globals; ClassicAPI backports
/// them, and this tree already half-built the pair — [`ClassInfo::class_file`]'s own doc says
/// the token is what FrameXML keys them on, and `compat/classic_api/lua_ext.rs`'s `tInvert`
/// exists precisely because pfUI flips this table (`pfUI.lua:227`).
///
/// Without it that flip inverts `nil`, so `pfUI.env.L["class"]` is **empty** and every lookup
/// through it quietly finds no class: the guild roster's colours and class icons
/// (`modules/socialmod.lua:18`), the friends list's, and `modules/panel.lua:359`. Nothing
/// raises — the module tests `if class then` and skips — which is why this read as "pfUI just
/// doesn't colour those lists here".
///
/// Both tables carry the same string. 1.12's `ChrClasses.dbc` has one name column, so the
/// gendered pair later clients ship cannot be derived from this data; publishing `_FEMALE` as
/// a copy is what the backport can honestly say, and leaving it out would hand a caller nil
/// where the other table answers.
fn publish_localized_class_names(&mut self, classes: &BTreeMap<u32, ClassInfo>) {
let lua = self.lua();
let Ok(male) = lua.create_table() else { return };
let Ok(female) = lua.create_table() else {
return;
};
for info in classes.values() {
if info.class_file.is_empty() {
continue;
}
if male
.set(info.class_file.as_str(), info.name.as_str())
.is_err()
|| female
.set(info.class_file.as_str(), info.name.as_str())
.is_err()
{
return;
}
}
let g = lua.globals();
let _ = g.set("LOCALIZED_CLASS_NAMES_MALE", male);
let _ = g.set("LOCALIZED_CLASS_NAMES_FEMALE", female);
}
}
@@ -193,6 +193,47 @@ mod tests {
s
}
/// The class table also publishes `LOCALIZED_CLASS_NAMES_MALE`/`_FEMALE`, keyed by the
/// `classFile` token and valued by the display name — the later client's tables, which
/// ClassicAPI backports and pfUI inverts to key its class colours (`pfUI.lua:227`, through
/// this tree's own `tInvert`). Without them that flip inverts nil, `pfUI.env.L["class"]` is
/// empty, and the guild and friends lists silently lose every class colour and icon.
#[test]
fn the_class_table_publishes_the_localized_class_name_globals_pfui_inverts() {
let s = seeded();
assert_eq!(
s.eval::<String>(r#"return LOCALIZED_CLASS_NAMES_MALE["WARRIOR"]"#)
.unwrap(),
"Warrior",
"keyed by the token, valued by the display name"
);
assert_eq!(
s.eval::<String>(r#"return LOCALIZED_CLASS_NAMES_FEMALE["DRUID"]"#)
.unwrap(),
"Druid",
"1.12 has one name column, so both tables carry it"
);
// The round trip pfUI actually performs: invert, then look up by display name.
assert_eq!(
s.eval::<String>(
r#"local t = tInvert(LOCALIZED_CLASS_NAMES_MALE) return t["Paladin"]"#
)
.unwrap(),
"PALADIN",
"the inverted table is what keys PFUI_CLASS_COLORS"
);
// A run with no install feeds no classes, and the tables are then empty rather than
// absent — `tInvert` of an empty table is an empty table, which is what the consumer
// already copes with.
let bare = UiScript::new().unwrap();
assert_eq!(
bare.eval::<Option<String>>(r#"return LOCALIZED_CLASS_NAMES_MALE and "present""#)
.unwrap(),
None,
"never fed, never published: the global stays absent rather than lying"
);
}
#[test]
fn get_race_info_returns_the_three_fields_pfui_reads() {
let s = seeded();
@@ -256,6 +256,11 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
// ClassicAPI's tInvert (script/compat/classic_api/lua_ext.rs): value->key table flip, a
// Blizzard utility 1.12 has no counterpart for.
"tInvert",
// ClassicAPI's token -> display-name class tables (script/class_info.rs), published from
// the same `ChrClasses` columns `C_CreatureInfo.GetClassInfo` answers from. The pair this
// tree's `tInvert` exists to flip: pfUI inverts the male one to key its class colours.
"LOCALIZED_CLASS_NAMES_MALE",
"LOCALIZED_CLASS_NAMES_FEMALE",
// ClassicAPI's backport of the modern client's bare math helpers
// (script/compat/classic_api/math_ext.rs). 1.12's Lua has `math.floor`/`ceil`/`min`/`max`
// and nothing else of the kind — no bare `Round`, no `Clamp` — so every name here is new.
+1
View File
@@ -388,6 +388,7 @@ with the load checked after each.
| Secure-attribute click dispatch (`type1`/`type2`/click-cast) | **built** | `SetAttribute`/`GetAttribute` were built as a bare key/value store, with this file's own header calling that "honest" about not claiming modern WoW's restricted-execution subsystem. The line was in the wrong place: a frame that stores attributes and does nothing when clicked is not a documented limitation, it is an inert frame. **pfUI drives every one of its unit frames this way whenever Clique is absent** — which is the default — setting `type1="target"`, `type2="menu"` and `EnableClickCast()` and installing **no `OnClick` script at all** (`api/unitframes.lua:1320-1326`). So the player frame could not be clicked to target, right-clicked for its menu, or used for click-casting, and neither could any other unit frame. Dispatched in `frame.rs`'s `dispatch_attribute_click`, called from `button.rs` **after** the `OnClick` handler for the same reason the nameplate selection is — a frame may have both and a raising hook must not eat the action. Handles `target`, `menu` (through `ClassicAPI_ToggleUnitMenu`, the same entry pfUI's own Lua path calls, so the two routes open one menu), `focus`, `spell` (cast on the frame's `unit`, which is what click-casting means) and `macro`/`macrotext`, with `shift-`/`ctrl-`/`alt-` prefixes falling back to the unmodified binding as the reference's lookup does. **What is dispatched is the action, not the security model**: no taint, no protected call, no combat lockdown — none of which 1.12 has |
| nampower's `*_SELF` aura edges | **built** | `BUFF_ADDED_SELF`/`BUFF_REMOVED_SELF`/`DEBUFF_ADDED_SELF`/`DEBUFF_REMOVED_SELF`, over the diff that already produced the `*_OTHER` pair — the player was **explicitly skipped** there, with a comment saying this half was unbuilt. Its helpful half is diffed separately because the halves fire different events. Why it matters: DoiteAuras reads the whole aura block **once**, at `PLAYER_ENTERING_WORLD`, and then relies entirely on these four (`UpdateAuras` is called nowhere else), so with none fired its table froze at login and every buff gained afterwards read as missing. Upstream has **eight**; the `BUFF_*_OTHER` pair is deliberately not produced — it would mean diffing the helpful half of every streamed unit on every edge for no reader — and `AuraEdge`'s three variants say so rather than leaving a dead arm firing events the gate must then allow. **Gate note**: routing the names through a `&str` parameter compiled and left `reference_ui.rs`'s event walker blind (it reads the literal at the call site), including to the two `*_OTHER` names already declared; the literals are back at the fire sites for that reason |
| Aura feed ordered before the `PLAYER_ENTERING_WORLD` fire | **fixed** | `feed_auras` and `feed_units` both sat in `UnitFeed` with **no ordering between them**, and `feed_units` is what fires `PLAYER_ENTERING_WORLD`. Whether an addon reading the aura block in that handler saw it was down to Bevy's ambiguous order — which is why DoiteAuras worked on a fresh login and broke after a `/reload`. A new `UnitFeedDispatch` set wraps `feed_units` alone so a sibling feed can order itself ahead of the fire; `feed_auras` now does. Same reasoning `feed_units` already applied internally to the XP pair ("ahead of the `PLAYER_ENTERING_WORLD` fire so its first update reads it") |
| `LOCALIZED_CLASS_NAMES_MALE` / `_FEMALE` | **built** | Published by `script/class_info.rs` whenever the app feeds the class table, from the same two `ChrClasses` columns `C_CreatureInfo.GetClassInfo` answers from: keyed by `classFile` (`WARRIOR`), valued by the display name (`Warrior`). Half of this pair was already here and the other half missing — `ClassInfo::class_file`'s own doc names these tables as what FrameXML keys on, and `compat/classic_api/lua_ext.rs`'s `tInvert` exists *because* pfUI flips this one (`pfUI.lua:227`). With the table absent that flip inverted nil, so `pfUI.env.L["class"]` was **empty** (measured: 0 entries against the live chain) and every lookup through it silently found no class — the guild roster's name colours and class icons (`modules/socialmod.lua:18`), the friends list's, and `modules/panel.lua:359`. Nothing raised, because the module tests `if class then` and skips, which is why it read as "pfUI just doesn't colour those lists here". Both tables carry the same string: 1.12's `ChrClasses.dbc` has one name column, so the gendered pair later clients ship cannot be derived — publishing `_FEMALE` as a copy is what this data honestly supports, and omitting it would hand a caller nil where its sibling answers. Absent, not empty, on a run that never fed the table |
| `C_Spell.GetSpellMechanicByID` / `GetSpellEffectMechanics` / `GetSpellPowerCost` | **built** | One probe for all three (`SpellDbcExtras`): same `Spell.dbc` row, same install site, and a caller asking for either has already paid the lookup. Its own probe rather than columns on `SpellLookup`, which has eleven construction sites with no answer for them. **`GetSpellMechanicByID` is three-way** and the consumer spells it out: nothing for an id no spell owns, `0` for a real spell with no mechanic, the id otherwise — it replaces a hand-kept spellID → mechanic table, so collapsing "unknown" into "none" would reclassify crowd control as plain damage. The enUS name is the second return, from `SpellMechanic.dbc` (which this tree already parsed). **`GetSpellEffectMechanics` is the per-effect sibling**, `EffectMechanic[3]` as `{m1, m2, m3}`, and it is not the same question: vanilla puts some mechanics on an effect rather than on the spell, so Rake's bleed answers `{0, 15, 0}` where `GetSpellMechanicByID` answers 0 — a macro conditional asking whether its target is bleeding gets the wrong answer from the spell-level column alone. Same three-way shape (nothing for an unknown id, three zeros for a spell with no mechanic anywhere). `SpellDisplay` already parsed the column for the cast-immunity scan, so this cost a field on the probe payload and nothing else. Its absence was one raise a session from `SuperCleveRoidMacros/ClassicAPI.lua:267`, which calls it unguarded. **`GetSpellPowerCost` answers a LIST** (`costs[1].cost`), one row, since 1.12 has a single cost per spell; it is the flat `manaCost` column **without** the reference's per-level and percentage terms (`GetPowerCost` `0x6e31b0`), which need the caster's level and max power — stated because it is the base cost a macro reader wants, not what a scaling spell would actually spend |
| nampower `GetUnitField` / `GetSpellRecField`, version 4.1.3 | **built** | `GetUnitField(unit, field)` is nampower v3+'s window onto the raw `UNIT_FIELD_AURA` block, and the reason it exists is that its slots are **stable**: every 1.12 aura getter walks the *compacted* list, so each index shifts the moment an earlier aura drops and nothing can be keyed on it across frames. Two fields: `"aura"` → slot → spell id, `"auraApplications"` → slot → **stacks minus one** (the wire's own encoding; pfUI reads it back as `(apps[slot] or 0) + 1`, so returning `stacks` would show every debuff one stack high). **Slots are reported 1-based** — debuffs 33..48, which both consumers loop literally — over benilla's 0-based wire index. Needed new state: `AuraState::slot`, since the VM's aura list was compacted and had dropped the raw index; filled at both app-side builders. Accepts a token or a raw GUID (pfUI passes `GetDebuffSlotMap(guid)`, DoiteAuras passes `"player"`), folding a guid back through `token_for_guid`. Reads the same filtered list every other getter does — hidden-from-aura-bar and tracking auras are already gone, which is what a debuff tracker wants, and the slot keying is unaffected because a dropped aura reads nil at its slot rather than shifting its neighbours. `GetSpellRecField(spellID, field)` is **partial on purpose**: `name` and `rank` are answered, `stackAmount`/`manaCost`/`spellIconID` are **nil rather than a plausible zero** — this client reaches no other column of that row, and a fabricated `manaCost` of 0 reads as "free", which is exactly the test DoiteAuras' Clearcasting rule turns on. `spellIconID` is not derivable at all: the icon is carried as an MPQ path, not as the numeric id that names it. With all three in place `GetNampowerVersion` moves 4.0.0 → **4.1.3**, which is the gate DoiteAuras gates on — bumped only *after* the machinery existed, per that constant's own standing rule not to claim what is absent. Measured: DoiteAuras goes from refusing to open to **0 load / 0 session errors**; pfUI's `libs/libdebuff.lua` ("GetUnitField Edition") stops falling back to manual slot shifting |
| nampower's six spellbook/duration verbs | **built** | `GetSpellNameAndRankForId(id)`, `GetSpellIdForName(name)`, `GetSpellSlotTypeIdForName(name)` → `slot, bookType`, `IsPassiveSpell(index, bookType)`, `GetSpellDuration(id[, ignoreModifiers])` → ms, and `GetPlayerAuraDuration(auraSlot)` → `spellID, durationMs, expirationTimeMs` (`script/compat/nampower.rs`). Signatures are **not inferred**: `SuperCleveRoidMacros/NampowerAPI.lua` is a shipped, documented wrapper over this whole surface and states each one's arguments, returns and units, which is what these were built against. Why they matter beyond a missing-global warning: DoiteAuras takes each as a *fast path* and otherwise falls back to a 200-slot `GetSpellName` scan **per lookup, per tracked aura, per condition evaluation**. None is expressible in 1.12: that client walks a book by index and reads a name, but has no id for what it finds and no way back from a name to either, and it exposes a running aura's remaining time (`GetPlayerBuffTimeLeft`) but no way to ask a spell how long its aura lasts *before* casting it. The two name→X verbs answer the **highest rank known**, as `CastSpellByName` does. **The two slot conventions deliberately disagree**: `GetUnitField` is 1-based (debuffs 33..48) and `GetPlayerAuraDuration` is raw 0-based (0..31 buffs, 32..47 debuffs) — each is what its own consumers loop over, and matching them to each other would break both. `GetSpellDuration` reads `SpellDuration.dbc` through the same `ui_cast_events::aura_duration_ms` that answers nampower's `SPELL_START_*` `arg7`, so the event and the query cannot disagree; it keeps upstream's three-way answer (nil for no such spell, 0 for a spell whose length this client will not state, ms otherwise) because a consumer treats 0 as absent and falls back, while a number it trusts and draws as a timer. `ignoreModifiers` is accepted and ignored: it asks for the base duration, and the base is all this client has (`spell/mods.rs` stores the server's `SPELLMOD_DURATION` cells but has no reader) |