diff --git a/crates/benilla-ui/src/script/class_info.rs b/crates/benilla-ui/src/script/class_info.rs index affa8856..c7a19c28 100644 --- a/crates/benilla-ui/src/script/class_info.rs +++ b/crates/benilla-ui/src/script/class_info.rs @@ -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) { + 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) { + 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); + } } diff --git a/crates/benilla-ui/src/script/compat/classic_api/creature_info.rs b/crates/benilla-ui/src/script/compat/classic_api/creature_info.rs index dac32cb5..eb0e5530 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/creature_info.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/creature_info.rs @@ -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::(r#"return LOCALIZED_CLASS_NAMES_MALE["WARRIOR"]"#) + .unwrap(), + "Warrior", + "keyed by the token, valued by the display name" + ); + assert_eq!( + s.eval::(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::( + 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::>(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(); diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 919e1803..01a8b15d 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -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. diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 199a5c04..6ab0bbe1 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -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) |