the main action bar pages, and <Include> runs Lua like the client

- the main bar's page arrows page for real, with the reference's own rule that a page a shown multibar displays is not also reachable by paging
- `<Include file="X.lua">` executes the file the way the client does — dispatch on the extension — so an addon pulling its libraries through XML stops losing them
- UIParentLoadAddOn lands, the on-demand loader behind every "open the panel" path
- nine constants take the reference's names back (NUM_GROUP_LOOT_FRAMES and friends — an addon reads the real name, not our prefix), and UnitCreatureType answers through the client's own three-stage resolver

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 18:54:39 +07:00
co-authored by Claude Fable 5
parent ae70c69443
commit a0fce18079
15 changed files with 592 additions and 39 deletions
+89 -8
View File
@@ -67,12 +67,12 @@
--
-- Found by the use-probe, which is the only column that touches anything.
--
-- `CURRENT_ACTIONBAR_PAGE` is deliberately NOT among them. It is the reference's mutable
-- page cursor (ActionButton.lua:64/74 walk it), and benilla does not page the main bar that
-- way — the bonus offset re-pages it instead. Defining it as a frozen 1 would hand every
-- addon a number that is silently wrong forever; absent, a reader fails loudly and we find
-- out. That is this arc's own rule (1203/1205/1230: a silent wrong answer costs more than
-- a missing one), applied to ourselves.
-- `CURRENT_ACTIONBAR_PAGE` used to be withheld from this list on the grounds that it is the
-- reference's MUTABLE page cursor and we did not page the main bar — "defining it as a
-- frozen 1 would hand every addon a number that is silently wrong forever". That reasoning
-- was right and is now discharged rather than overruled: the bar pages, the global is live
-- state the paged-id formula reads, and it is declared with the paging verbs below rather
-- than here among the true constants. A frozen 1 would still be a lie; this one moves.
NUM_ACTIONBAR_PAGES = 6
NUM_ACTIONBAR_BUTTONS = 12
BOTTOMLEFT_ACTIONBAR_PAGE = 6
@@ -134,6 +134,80 @@
-- feedback events per-button; our bar-level fan needs the full set in one place.
BENILLA_ACTION_BUTTONS = {}
-- ── Main-bar paging (ref ActionButton.lua l.1-12 and l.63-101) ───────────────────────
-- The main bar has six pages, and we already hold every slot they address: the app owns
-- the authoritative 1..120 action table, so the DATA for pages 2-6 was always there and
-- only the page SELECTOR was missing. Its absence was on screen, not just in the API —
-- the page arrows below shipped as art with no OnClick, which this file's own comment
-- admitted ("ART ONLY - we have no bar paging, so the clicks are no-ops").
--
-- `Bartender2.lua:686` is the corpus tell: three bare `ChangeActionBarPage()` calls, and
-- the addon died on the nil global at session start.
CURRENT_ACTIONBAR_PAGE = 1
-- "Table of actionbar pages and whether they're viewable or not" (the reference's own
-- comment). The page walk below honours it rather than counting, which is what makes the
-- next two lines work at all.
--
-- **Pages 5 and 6 are deliberately NOT viewable, and that is the reference's own rule
-- applied to our configuration rather than a deviation from it.**
-- `MultiActionBar_Update` (ref MultiActionBars.lua l.49-60) blanks
-- `VIEWABLE_ACTION_BAR_PAGES[BOTTOMLEFT_ACTIONBAR_PAGE]` whenever MultiBarBottomLeft is
-- SHOWN — a page a multibar already displays must not also be reachable on the main bar,
-- or paging up shows you the twelve actions already on screen below. benilla's two bottom
-- bars are always-on with no `SHOW_MULTI_ACTIONBAR_*` state (0270, the director's call),
-- and they own pages 6 and 5 (MultiBars.xml: BottomLeft = actions 61..72, BottomRight =
-- 49..60), so the two rows are blanked at declaration instead of by a toggle.
--
-- MultiBarLeft/Right (pages 4 and 3) stay viewable because ours ship HIDDEN (1219), which
-- is the same state the reference is in with SHOW_MULTI_ACTIONBAR_3/4 unset.
VIEWABLE_ACTION_BAR_PAGES = {1, 1, 1, 1}
VIEWABLE_ACTION_BAR_PAGES[BOTTOMRIGHT_ACTIONBAR_PAGE] = nil
VIEWABLE_ACTION_BAR_PAGES[BOTTOMLEFT_ACTIONBAR_PAGE] = nil
-- `ChangeActionBarPage()` takes NO arguments — every reference caller sets
-- CURRENT_ACTIONBAR_PAGE first and then calls this to apply it (l.79, l.100), and
-- Bartender2 calls it bare.
--
-- Upstream it is a client binding; here it is FrameXML, deliberately. Our paged action id
-- is computed in Lua by `BenillaActionBar_ActionFor`, so the page IS this global and the
-- only work left for the verb is the repaint the reference gets by firing
-- ACTIONBAR_PAGE_CHANGED. Addons CALL this, they never replace it, so the divergence is
-- unobservable from Lua — unlike a name we shadow, which is 1222's class.
function ChangeActionBarPage()
for i = 1, table.getn(BENILLA_ACTION_BUTTONS) do
BenillaActionButton_Update(BENILLA_ACTION_BUTTONS[i])
end
end
function ActionBar_PageUp()
local page = CURRENT_ACTIONBAR_PAGE + 1
local nextPage
for i = page, NUM_ACTIONBAR_PAGES do
if VIEWABLE_ACTION_BAR_PAGES[i] then nextPage = i break end
end
-- Past the last viewable page, wrap to 1 — the reference wraps to the LITERAL 1 here
-- rather than to the first viewable page, which is its own asymmetry with PageDown
-- (which rescans). Kept, because it is observable: with page 1 blanked, paging up off
-- the end lands on a page the down-walk would have skipped.
CURRENT_ACTIONBAR_PAGE = nextPage or 1
ChangeActionBarPage()
end
function ActionBar_PageDown()
local page = CURRENT_ACTIONBAR_PAGE - 1
local prevPage
for i = page, 1, -1 do
if VIEWABLE_ACTION_BAR_PAGES[i] then prevPage = i break end
end
if not prevPage then
for i = NUM_ACTIONBAR_PAGES, 1, -1 do
if VIEWABLE_ACTION_BAR_PAGES[i] then prevPage = i break end
end
end
CURRENT_ACTIONBAR_PAGE = prevPage
ChangeActionBarPage()
end
-- Seat a TOP-LEVEL frame that anchors INTO the bar art (the bag bar + backpack toggle,
-- BagFrame.xml; the micro-button row, MicroMenu.xml) one frame level above the art, so its
-- icons draw over the bar plate instead of under it.
@@ -224,6 +298,11 @@
local base = 0
if offset > 0 then
base = (6 + offset - 1) * 12
else
-- The paged main bar. A bonus page (stance/form/stealth) OUTRANKS it, which is
-- why this is the else arm and not an addition: the reference's own
-- `ActionButton_GetPagedID` takes the bonus branch first.
base = (CURRENT_ACTIONBAR_PAGE - 1) * 12
end
return base + index
end
@@ -1485,8 +1564,8 @@
</CheckButton>
<!-- Page up/down: 32x32 at CENTER of the art frame's TOPLEFT +(522,-22)/(522,-42)
(ref-ActionBarFrame.xml l.159-217). ART ONLY — we have no bar paging, so the
clicks are no-ops (no OnClick handler). -->
(ref-ActionBarFrame.xml l.159-217). Live now that the bar pages — they were
art with no OnClick for as long as `CURRENT_ACTIONBAR_PAGE` did not exist. -->
<Button name="ActionBarUpButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
@@ -1495,6 +1574,7 @@
<NormalTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollUpButton-Up"/>
<PushedTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollUpButton-Down"/>
<HighlightTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollUpButton-Highlight" alphaMode="ADD"/>
<Scripts><OnClick>ActionBar_PageUp()</OnClick></Scripts>
</Button>
<Button name="ActionBarDownButton">
<Size><AbsDimension x="32" y="32"/></Size>
@@ -1504,6 +1584,7 @@
<NormalTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollDownButton-Up"/>
<PushedTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollDownButton-Down"/>
<HighlightTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollDownButton-Highlight" alphaMode="ADD"/>
<Scripts><OnClick>ActionBar_PageDown()</OnClick></Scripts>
</Button>
</Frames>
</Frame>
+5 -5
View File
@@ -56,8 +56,8 @@
established cross-window reuse `TrainerFrame.xml`/`BagFrame.xml` already lean on).
-->
<Script><![CDATA[
BENILLA_BANK_ITEM_SLOTS = 24 -- NUM_BANKGENERIC_SLOTS (ref BankFrame.lua:3)
BENILLA_BANK_BAG_SLOTS = 6 -- NUM_BANKBAGSLOTS (ref BankFrame.lua:4)
NUM_BANKGENERIC_SLOTS = 24 -- (ref BankFrame.lua:3)
NUM_BANKBAGSLOTS = 6 -- (ref BankFrame.lua:4)
function BankFrame_OnLoad()
local frame = this
@@ -95,7 +95,7 @@
-- Repaint every one of the 24 generic slots (PLAYERBANKSLOTS_CHANGED names ONE slot; the
-- spec allows repainting all 24 — cheap, and spares tracking a slot->button map).
function BenillaBankFrame_RepaintItems()
for i = 1, BENILLA_BANK_ITEM_SLOTS do
for i = 1, NUM_BANKGENERIC_SLOTS do
local button = getglobal("BankItem" .. i)
if button then BenillaBagSlot_Update(button) end
end
@@ -107,7 +107,7 @@
-- afford color; the whole row hidden once GetNumBankSlots() reports `full`.
function BenillaBankFrame_UpdateBagRow()
local numSlots, full = GetNumBankSlots()
for i = 1, BENILLA_BANK_BAG_SLOTS do
for i = 1, NUM_BANKBAGSLOTS do
local button = getglobal("BankBagButton" .. i)
if button then
local texture = BenillaGetBankBagTexture(i)
@@ -188,7 +188,7 @@
-- CloseBankBagFrames (ref BankFrame.lua:119-123), over our popout naming.
function CloseBankBagFrames()
for j = 1, BENILLA_BANK_BAG_SLOTS do
for j = 1, NUM_BANKBAGSLOTS do
local frame = getglobal("BenillaBankBagFrame" .. j)
if frame and frame:IsShown() then frame:Hide() end
local button = getglobal("BankBagButton" .. j)
@@ -70,6 +70,39 @@
function TEXT(text)
return text
end
-- ── UIParentLoadAddOn (ref UIParent.lua l.638-647) ────────────────────────────────────
-- The on-demand loader every "open the auction house / tradeskill / talent panel" path
-- goes through, and the one 5 corpus addons name — FuBar_MCPFu, Fubar_RareTrackerFu, MCP,
-- MikScrollingBattleText, SpecialTalent: five separate addons, not one library replicated
-- (1207/1240).
--
-- Every piece was already here and only the join was missing: `LoadAddOn` answers the
-- reference's own `(1, nil)` / `(nil, reason)` pair, `message` and `TEXT` are directly
-- above, and the `ADDON_*` reason words come from the real `GlobalStrings.lua` we RUN off
-- the patch chain rather than copy (1234).
--
-- **`FailedAddOnLoad` is the reference's own latch, and it is not decoration.** Without it
-- an addon that retries the load from a handler raises a modal on every attempt; with it
-- the failure shows once per addon name per session. It is exactly the detail this would
-- have lacked had it been reconstructed from the name instead of read.
--
-- Every reason our `LoadAddOn` can answer resolves to a string: MISSING, DISABLED,
-- NOT_DEMAND_LOADED, DEP_MISSING and DEP_DISABLED all have an `ADDON_..reason` word
-- (GlobalStrings l.16-31), the same reason set wow-re's `ui.md` l.2396 carves off
-- `0x51e780`. So the `getglobal` below cannot hand `format` a nil.
FailedAddOnLoad = {};
function UIParentLoadAddOn(name)
local loaded, reason = LoadAddOn(name)
if not loaded then
if not FailedAddOnLoad[name] then
message(format(TEXT(ADDON_LOAD_FAILED), name, TEXT(getglobal("ADDON_" .. reason))))
FailedAddOnLoad[name] = true
end
end
return loaded
end
]]></Script>
<!-- The dialog button faces (ref l.28-37). Same three-piece art as UIPanelButtonTemplate's,
@@ -233,7 +233,7 @@
-- ── This window's own constants (ref-PaperDollFrame.lua:1-4, Benilla-prefixed) ────────────
BENILLA_NUM_STATS = 5 -- NUM_STATS
BENILLA_NUM_RESISTANCE_TYPES = 5 -- NUM_RESISTANCE_TYPES
BENILLA_ATTACK_POWER_MAGIC_NUMBER = 14 -- ATTACK_POWER_MAGIC_NUMBER
ATTACK_POWER_MAGIC_NUMBER = 14 -- ATTACK_POWER_MAGIC_NUMBER
-- MagicResFrame1..5's ref `id=` attrs (ref-PaperDollFrame.xml:654-759) — arcane/fire/nature/
-- frost/shadow; Holy(1) never rides the paper doll (only 5 of the 6 schools show here).
BENILLA_MAGICRES_IDS = { 6, 2, 3, 4, 5 }
@@ -689,7 +689,7 @@
local text = getglobal(prefix .. "AttackPowerFrameStatText")
PaperDollFormatStat(MELEE_ATTACK_POWER, base, posBuff, negBuff, frame, text)
frame.tooltipSubtext = format(MELEE_ATTACK_POWER_TOOLTIP,
math.max(base + posBuff + negBuff, 0) / BENILLA_ATTACK_POWER_MAGIC_NUMBER)
math.max(base + posBuff + negBuff, 0) / ATTACK_POWER_MAGIC_NUMBER)
end
-- ref PaperDollFrame_SetAttackBothHands (l.391-417) — the weapon-skill line (decision 0208's
@@ -837,7 +837,7 @@
local base, posBuff, negBuff = UnitRangedAttackPower(unit)
PaperDollFormatStat(RANGED_ATTACK_POWER, base, posBuff, negBuff, frame, text)
frame.tooltipSubtext = format(RANGED_ATTACK_POWER_TOOLTIP,
math.max(base + posBuff + negBuff, 0) / BENILLA_ATTACK_POWER_MAGIC_NUMBER)
math.max(base + posBuff + negBuff, 0) / ATTACK_POWER_MAGIC_NUMBER)
end
-- ref PaperDollFormatStat (l.804-833).
@@ -99,9 +99,16 @@
hides the one that owns it (`crates/benilla/src/ui_loot_roll.rs` fires both).
-->
<Script><![CDATA[
-- LootFrame.lua l.2's NUM_GROUP_LOOT_FRAMES, renamed to the house BENILLA_ prefix (the same
-- treatment LootFrame.xml gave LOOTFRAME_NUMBUTTONS -> BENILLA_LOOT_NUMBUTTONS).
BENILLA_NUM_GROUP_LOOT_FRAMES = 4
-- LootFrame.lua l.2's NUM_GROUP_LOOT_FRAMES, under the REFERENCE's own name.
--
-- It carried the house `BENILLA_` prefix until an addon died on the bare one:
-- `Bongos_RollBar/bar.lua:85` is `for i=1, NUM_GROUP_LOOT_FRAMES do` and got a nil limit.
-- That is 1222's shadowed-global class again — the fifth instance — and the rule it
-- settled applies to a CONSTANT an addon reads exactly as it does to a frame: our name for
-- a global is as load-bearing as our name for a function. The prefix is right only where
-- the reference has no counterpart; here it has one, with the same value (4) counting the
-- same four frames, which already carry the reference's bare names two paragraphs down.
NUM_GROUP_LOOT_FRAMES = 4
-- The bind-on-pickup confirm (ref StaticPopup.lua l.1371-1382, verbatim bar the TEXT()
-- indirection this house dialect drops). Need/Greed on a BoP roll never reaches the wire
@@ -138,7 +145,7 @@
-- Timer StatusBar's max before showing it (fields set BEFORE Show(), so OnShow below can
-- already read frame.rollID).
function GroupLootFrame_OpenNewFrame(rollId, rollTime)
for i = 1, BENILLA_NUM_GROUP_LOOT_FRAMES do
for i = 1, NUM_GROUP_LOOT_FRAMES do
local frame = getglobal("GroupLootFrame" .. i)
if frame and not frame:IsVisible() then
frame.rollID = rollId
@@ -59,7 +59,7 @@
AUTOCAST_* .
-->
<Script><![CDATA[
BENILLA_NUM_PET_ACTION_SLOTS = 10
NUM_PET_ACTION_SLOTS = 10
-- The token textures, quoted verbatim from ref PetActionBarFrame.lua:6-12. They are
-- GLOBALS on purpose: `GetPetActionInfo` returns the NAME of one for a token slot, and the
@@ -325,7 +325,7 @@
-- the filled UI-Quickslot2 to the empty UI-Quickslot.
function PetActionBar_Update()
local usable = GetPetActionsUsable()
for i = 1, BENILLA_NUM_PET_ACTION_SLOTS do
for i = 1, NUM_PET_ACTION_SLOTS do
local button = getglobal("PetActionButton" .. i)
local name, subtext, texture, isToken, isActive, autoCastAllowed, autoCastEnabled = GetPetActionInfo(i)
button.isToken = isToken
@@ -411,7 +411,7 @@
if frame.clock > BENILLA_PET_AUTOCAST_PERIOD then
frame.clock = frame.clock - BENILLA_PET_AUTOCAST_PERIOD
end
for i = 1, BENILLA_NUM_PET_ACTION_SLOTS do
for i = 1, NUM_PET_ACTION_SLOTS do
BenillaPetAutocast_Update(getglobal("PetActionButton" .. i), frame.clock)
end
end
@@ -81,7 +81,7 @@
UNIT_LEVEL_TEMPLATE = UNIT_LEVEL_TEMPLATE or "Level %d" -- :4436
PET_DIET_TEMPLATE = PET_DIET_TEMPLATE or "Diet: %s" -- :3037
BENILLA_NUM_PET_STATS = 5 -- NUM_PET_STATS (ref l.2)
NUM_PET_STATS = 5 -- (ref l.2)
BENILLA_NUM_PET_RESISTANCE_TYPES = 5 -- NUM_PET_RESISTANCE_TYPES (ref l.1)
-- ── The page ────────────────────────────────────────────────────────────────────────────
@@ -244,7 +244,7 @@
-- duplication; unifying them would mean inventing a prefix the reference's own
-- `PetStatFrame`/`CharacterStatFrame` naming doesn't share).
function PetPaperDollFrame_SetStats()
for i = 1, BENILLA_NUM_PET_STATS do
for i = 1, NUM_PET_STATS do
local label = getglobal("PetStatFrame" .. i .. "Label")
local text = getglobal("PetStatFrame" .. i .. "StatText")
local frame = getglobal("PetStatFrame" .. i)
+10 -10
View File
@@ -67,9 +67,9 @@
to be the real widget method rather than a benilla global.
-->
<Script><![CDATA[
BENILLA_QUESTLOG_ROWS = 6 -- QUESTS_DISPLAYED (pin §2, QuestLogFrame.lua:1)
BENILLA_QUESTLOG_MAX_DISPLAY = 20 -- MAX_QUESTLOG_QUESTS (pin §2, :6) — display cap only
BENILLA_QUESTLOG_MAX_OBJECTIVES = 10 -- MAX_OBJECTIVES (pin §3, QuestLogFrame.lua:3)
QUESTS_DISPLAYED = 6 -- QUESTS_DISPLAYED (pin §2, QuestLogFrame.lua:1)
MAX_QUESTLOG_QUESTS = 20 -- MAX_QUESTLOG_QUESTS (pin §2, :6) — display cap only
MAX_OBJECTIVES = 10 -- MAX_OBJECTIVES (pin §3, QuestLogFrame.lua:3)
-- Reward/choice row cap: the SAME v1 cap `QuestFrame.xml`'s BENILLA_QUEST_MAX_ITEMS uses (the
-- ref's own MAX_NUM_ITEMS is 10, QuestFrame.lua:2 — we halve it there too).
BENILLA_QUESTLOG_MAX_ITEMS = 6
@@ -270,7 +270,7 @@
-- pill's middle piece then shrinks to the text (ref sizes it every update). The >10
-- guard skips the first-ever paint, where the host hasn't measured the string yet
-- (GetWidth 0 would collapse the pill; the next update — and every one after — sizes it).
getglobal("QuestLogCount"):SetText(string.format("Quests: |cffffffff%d/%d|r", numQuests, BENILLA_QUESTLOG_MAX_DISPLAY))
getglobal("QuestLogCount"):SetText(string.format("Quests: |cffffffff%d/%d|r", numQuests, MAX_QUESTLOG_QUESTS))
local countWidth = getglobal("QuestLogCount"):GetWidth()
if countWidth and countWidth > 10 then
getglobal("QuestLogCountMiddle"):SetWidth(countWidth)
@@ -293,14 +293,14 @@
end
-- Clamp the faux-scroll offset (pin §2's FauxScrollFrame_Update clamp).
local maxOffset = numEntries - BENILLA_QUESTLOG_ROWS
local maxOffset = numEntries - QUESTS_DISPLAYED
if maxOffset < 0 then maxOffset = 0 end
if questLogOffset > maxOffset then questLogOffset = maxOffset end
if questLogOffset < 0 then questLogOffset = 0 end
local selection = GetQuestLogSelection()
for i = 1, BENILLA_QUESTLOG_ROWS do
for i = 1, QUESTS_DISPLAYED do
local button = getglobal("QuestLogTitle" .. i)
local index = i + questLogOffset
if index <= numEntries then
@@ -362,7 +362,7 @@
-- vertex-colored to the row's own difficulty color (that green bar in the ref shot IS
-- this vertex color on the white ADD gradient).
local hl = getglobal("QuestLogSelectHighlight")
if selection > questLogOffset and selection <= questLogOffset + BENILLA_QUESTLOG_ROWS
if selection > questLogOffset and selection <= questLogOffset + QUESTS_DISPLAYED
and selection <= numEntries then
local selButton = getglobal("QuestLogTitle" .. (selection - questLogOffset))
local _, selLevel, _, selIsHeader = GetQuestLogTitle(selection)
@@ -563,7 +563,7 @@
getglobal("QuestLogTimerText"):SetText("")
getglobal("QuestLogDescriptionTitle"):SetText("")
getglobal("QuestLogDescription"):SetText("")
for i = 1, BENILLA_QUESTLOG_MAX_OBJECTIVES do
for i = 1, MAX_OBJECTIVES do
getglobal("QuestLogObjective" .. i):SetText("")
end
getglobal("QuestLogRewardTitleText"):SetText("")
@@ -819,7 +819,7 @@
getglobal("QuestLogDescriptionTitle"):SetText("Description")
local numObjectives = GetNumQuestLeaderBoards()
for i = 1, BENILLA_QUESTLOG_MAX_OBJECTIVES do
for i = 1, MAX_OBJECTIVES do
local line = getglobal("QuestLogObjective" .. i)
if i <= numObjectives then
local text, kind, finished = GetQuestLogLeaderBoard(i)
@@ -847,7 +847,7 @@
-- widget backs GetQuestLogRequiredMoney yet) — that ref branch collapses into the rest.
-- The objective-less TIMED branch (ref :432-437) is transcribed: a timed quest with no
-- leaderboard lines hangs the header off the countdown row, not off ObjectivesText.
local objAnchorIndex = math.min(numObjectives, BENILLA_QUESTLOG_MAX_OBJECTIVES)
local objAnchorIndex = math.min(numObjectives, MAX_OBJECTIVES)
local descTitle = getglobal("QuestLogDescriptionTitle")
if objAnchorIndex > 0 then
descTitle:SetPoint("TOPLEFT", getglobal("QuestLogObjective" .. objAnchorIndex), "BOTTOMLEFT", 0, -10)
@@ -1068,10 +1068,18 @@ fn the_reference_action_bar_constants_are_defined() {
"zBar.lua:40's numeric for must have a limit"
);
assert!(
s.eval::<bool>("return CURRENT_ACTIONBAR_PAGE == nil").unwrap(),
"CURRENT_ACTIONBAR_PAGE is mutable page state we do not keep — nil fails loudly, a frozen 1 lies"
// `CURRENT_ACTIONBAR_PAGE` was asserted ABSENT here, on the grounds that a frozen 1 lies where
// nil fails loudly. That objection is discharged, not overruled: the bar pages now, so the
// global is live state the paged-id formula reads rather than a frozen number. It is therefore
// asserted as state — present, and MOVING — instead of as one of the constants above.
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
s.run("ActionBar_PageUp()").unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
2,
"a frozen 1 would still be a lie — this one has to move"
);
s.run("ActionBar_PageDown()").unwrap();
}
/// **The reference's two-level action-button split, both halves inheritable by name.**
@@ -1155,3 +1163,102 @@ fn both_reference_action_button_templates_are_inheritable() {
"BenillaActionButtonTemplate's 48 inherits= sites must be untouched by the split"
);
}
/// Main-bar paging — `CURRENT_ACTIONBAR_PAGE` and the three verbs around it.
///
/// The data was always there (the app owns all 120 action slots); only the selector was missing,
/// and its absence was visible on screen as page arrows with no `OnClick`. `Bartender2.lua:686`
/// died on the nil `ChangeActionBarPage` at session start.
///
/// Two things are asserted that a reconstruction would get wrong. **A bonus page outranks the
/// paged one** — the reference's own `ActionButton_GetPagedID` takes the bonus branch first, so
/// paging must be the `else` arm and not an addition. And **page-up wraps to the literal page 1**
/// while page-down rescans for the last viewable page: that asymmetry is the reference's, and it
/// is observable the moment a page is blanked from `VIEWABLE_ACTION_BAR_PAGES`.
#[test]
fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in ["Cooldown.xml", "ActionBar.xml"] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
}
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"page 1 button 1 is action 1"
);
// Page up walks to 2, so button 1 shows action 13.
s.run("ActionBar_PageUp()").unwrap();
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 2);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
13
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton12)")
.unwrap(),
24
);
// Down again, and below page 1 it rescans to the LAST viewable page.
s.run("ActionBar_PageDown()").unwrap();
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
s.run("ActionBar_PageDown()").unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
4,
"page-down off the bottom rescans for the last VIEWABLE page — 4, not 6, because the \
always-on bottom multibars own pages 5 and 6"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
37
);
// The pages the bottom bars already display are unreachable from the main bar, which is what
// MultiActionBar_Update does on the reference the moment those bars are shown. Without this,
// paging up lands on a duplicate of the twelve actions already on screen below.
assert!(s
.eval::<bool>(
"return VIEWABLE_ACTION_BAR_PAGES[5] == nil and VIEWABLE_ACTION_BAR_PAGES[6] == nil"
)
.unwrap());
s.run("CURRENT_ACTIONBAR_PAGE = 4 ActionBar_PageUp()")
.unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
1,
"walking up from the last viewable page skips 5 and 6 and wraps to the LITERAL 1 — the \
reference's own asymmetry with page-down, which rescans instead"
);
// A bonus page outranks the paged one entirely: with an offset up, the page is ignored.
s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap();
s.set_bonus_bar_offset(1);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
73,
"bonus offset 1 is action 73, whatever page the main bar is on"
);
s.set_bonus_bar_offset(0);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
25,
"and the page comes back when the form drops"
);
}
@@ -415,3 +415,66 @@ fn the_list_reports_an_out_of_date_interface_like_the_char_select_twin() {
"no `## Interface` at all is silent, not out of date"
);
}
/// `UIParentLoadAddOn(name)` — the on-demand loader 5 corpus addons name (FuBar_MCPFu,
/// Fubar_RareTrackerFu, MCP, MikScrollingBattleText, SpecialTalent), and which raised as a nil
/// global until every piece it needs was joined up in `BasicControls.xml`.
///
/// Two things are asserted, and the second is the one a reconstruction from the name would miss:
/// it answers `LoadAddOn`'s own falsey result so a caller can branch, and `FailedAddOnLoad` shows
/// the failure **once per addon name**, not once per attempt (ref `UIParent.lua` l.641-643).
#[test]
fn ui_parent_load_addon_reports_once_and_returns_the_load_result() {
let s = UiScript::new().unwrap();
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/BasicControls.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
// The two GlobalStrings this leans on. The real client has them from FrameXML's first file and
// the corpus survey runs the real `GlobalStrings.lua`; a bare VM does not, so they are seated
// here rather than left to make `format` raise on a nil.
s.run(
r#"
ADDON_LOAD_FAILED = "Couldn't load %s: %s"
ADDON_MISSING = "Missing"
shown = {}
message = function(t) table.insert(shown, t) end
"#,
)
.unwrap();
// A name no registry has: LoadAddOn answers (nil, "MISSING"), so this answers falsey.
assert!(
s.eval::<bool>(r#"return UIParentLoadAddOn("NoSuchAddonAnywhere") == nil"#)
.unwrap(),
"must hand back LoadAddOn's own falsey result for a caller to branch on"
);
assert_eq!(
s.eval::<String>("return shown[1]").unwrap(),
"Couldn't load NoSuchAddonAnywhere: Missing",
"the reason word resolves through ADDON_..reason"
);
// The latch: a retrying addon gets one modal, not one per attempt.
for _ in 0..3 {
s.run(r#"UIParentLoadAddOn("NoSuchAddonAnywhere")"#)
.unwrap();
}
assert_eq!(
s.eval::<i64>("return table.getn(shown)").unwrap(),
1,
"FailedAddOnLoad must suppress every repeat for the same name"
);
// A DIFFERENT name is a different latch entry, so it still reports.
s.run(r#"UIParentLoadAddOn("AlsoNotThere")"#).unwrap();
assert_eq!(s.eval::<i64>("return table.getn(shown)").unwrap(), 2);
}
+9 -1
View File
@@ -664,7 +664,15 @@ fn creature_type_word(t: u32) -> Option<&'static str> {
7 => "Humanoid",
8 => "Critter",
9 => "Mechanical",
_ => return None, // 10 "Not specified" shows nothing
// The shipped `CreatureType.dbc` is **1..11 dense**, not 1..9 — this table stopped two
// rows early. 11 is reachable and wow-re's own nameplate filter tests for it
// (`0x605570 == 0xb`).
11 => "Totem",
// 10 is "Not specified" in the DBC. Deliberately still None: this word is the tooltip's
// level-line class slot and a literal "Not specified" there is noise. The cost is named
// rather than hidden — `UnitCreatureType` shares this field, so it answers nil for a
// type-10 unit where the reference answers the DBC word.
_ => return None,
})
}
+61
View File
@@ -288,6 +288,17 @@ pub fn join_ref(base: &str, path: &str) -> String {
out.join("/")
}
/// The `.lua` test `0x6ede10` opens with: `strrchr(path, '.')` on the **whole resolved path**,
/// then a **case-insensitive** compare against `".lua"` (`0x8710c8`, through `0x64a4c0`).
///
/// Deliberately the last dot anywhere in the path rather than the basename's, because that is what
/// `strrchr` does — a directory carrying a dot and a file carrying none would take the same branch
/// in the client as it does here.
fn has_lua_suffix(path: &str) -> bool {
path.rsplit_once('.')
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("lua"))
}
/// The directory part of a provider path — `""` for a bare name.
fn dir_of(path: &str) -> &str {
match path.rfind('/') {
@@ -414,6 +425,29 @@ impl Loader<'_> {
for item in &doc.items {
match item {
TopLevel::Include(path) => self.do_include(path),
// **A DELIBERATE DIVERGENCE, and a strict superset of the reference.**
//
// `<Script file=X>` does NOT share `<Include>`'s path law. `0x6ee070`-`0x6ee079`
// tests X itself for a separator and, when it has one, uses X **verbatim** — no
// `dirname(referrer)` prefix, and no `..` collapse either, since that arm never
// enters `0x6ede10`. Only a *bare* name gets `dirname(referrer) + name`.
//
// A verbatim value cannot open on 1.12. Resolution runs through `0x647e60` against
// a hash index whose key is each file's path **relative to the scan root**, and the
// root is the process CWD (Storm's base-path global `0xc52418` has no writer
// image-wide, so `0x646ebc` substitutes `"."`). The basename retry at `0x647ed3` is
// real code but off — it needs a flag bit that `0x648be0(0)` clears at startup. So
// `Libs\Ace\Ace.lua` misses every leg: full-string, base-prefixed and the MPQ
// chain. There is no per-addon or per-document current directory anywhere in the
// client. (wow-re `scratch/include-lua-dispatch.md` §4.1.)
//
// We resolve it against the including document's directory anyway. That converts a
// guaranteed failure into a success and **cannot break anything that worked on the
// reference**, because under 1.12 there is no install-root `Libs\` for the verbatim
// value to have found. The corpus addons carrying the form are TBC-era anyway —
// all three report `iface=20400` and ship LibStub/CallbackHandler-1.0, none of
// which existed in 1.12 — so reproducing the failure would buy nothing and cost
// `FuBar_AtlasFu`, which works today.
TopLevel::Script(ScriptRef::File(path)) => {
let joined = join_ref(self.base(), path);
match (self.files)(&joined) {
@@ -470,6 +504,33 @@ impl Loader<'_> {
));
return;
};
// **`<Include>` dispatches on the EXTENSION, and a `.lua` target is RUN, not parsed.**
//
// `0x6ede10` is not "the XML loader" — it is *load one file by path*, and its first act
// after the `..` collapse is a case-insensitive `.lua` suffix test on the **resolved** path
// (`0x6edee6`-`0x6edf0f`: `strrchr(path,'.')`, then a case-insensitive compare against
// `".lua"` @`0x8710c8`). Extension, never a content sniff — no byte of the file has been
// read at that point. A match runs the chunk (`0x6edf0f call 0x704bc0`, chunk name
// `"@<resolved path>"` via `0x8716e0`); anything else is opened and parsed as XML.
//
// `<Include file=X>` is a **recursion into that same routine** (`0x6ee00d`), so it inherits
// the dispatch for free: there is no `<Include>`-specific Lua code in the client at all,
// and it is the same mechanism that makes a `.lua` line in a `.toc` work. Which is also the
// internal evidence that this was ours and not the corpus's — our own `.toc` loader has
// always split on extension (`ui_script::addons`, 1185), and only this arm never did.
//
// Three addons ship the form — FonzAppraiser (67 sites), AckisRecipeList (24), FonzSummon
// (22): an authoring habit rather than a convention, but each lost its ENTIRE library set
// here, because `embeds.xml` is nothing but these lines. (wow-re
// `scratch/include-lua-dispatch.md`, §5-verified.)
if has_lua_suffix(&joined) {
if let Err(e) = self.run(crate::source::chunk(&bytes), &joined) {
self.report
.errors
.push(format!("<Include file=\"{path}\">: {e}"));
}
return;
}
// An included document is the one place text is forced: roxmltree parses `&str` (1193).
match framexml::parse(&crate::source::decode(&bytes)) {
Ok(sub) => {
+80
View File
@@ -247,6 +247,86 @@ mod loader_tests {
assert_eq!(s.eval::<f32>("return PinChild:GetHeight()").unwrap(), 200.0);
}
/// **`<Include file="X.lua">` RUNS the file** — the client's `<Include>` is a recursion into
/// its load-one-file routine `0x6ede10`, whose first act is a case-insensitive `.lua` suffix
/// test on the resolved path. It never sniffs content; it never parses a `.lua` as XML.
///
/// We parsed every target as FrameXML, so a `.lua` target died `unknown token at 1:1` and took
/// the whole library set with it. Three corpus addons load nothing but these lines —
/// FonzAppraiser, AckisRecipeList, FonzSummon — and `embeds.xml` is the whole of their Ace
/// dependency chain.
///
/// The mixed document is the point: one `<Include>` of each kind, in one file, both landing.
#[test]
fn include_runs_a_lua_target_and_still_parses_an_xml_one() {
let s = UiScript::new().unwrap();
let provider = |path: &str| -> Option<Vec<u8>> {
match path {
"libs/Lib.lua" => Some(b"IncludedLua = 41 + 1".to_vec()),
// Case-insensitive, exactly as `0x64a4c0` compares it.
"libs/Shouty.LUA" => Some(b"ShoutyLua = true".to_vec()),
"Sub.xml" => Some(br#"<Ui><Frame name="FromXmlInclude"/></Ui>"#.to_vec()),
_ => None,
}
};
let doc = parse(
r#"<Ui>
<Include file="libs\Lib.lua"/>
<Include file="libs\Shouty.LUA"/>
<Include file="Sub.xml"/>
<Frame name="FromMain"/>
</Ui>"#,
);
let report = load(&s, &doc, &provider);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(
s.eval::<i64>("return IncludedLua").unwrap(),
42,
"a .lua Include must be executed as a chunk, not parsed as a document"
);
assert!(
s.eval::<bool>("return ShoutyLua").unwrap(),
"case-insensitive suffix"
);
// ...and the XML arm is untouched: both frames still exist.
assert_eq!(report.frames, 2);
assert!(s
.eval::<bool>("return FromXmlInclude ~= nil and FromMain ~= nil")
.unwrap());
}
/// A raise inside an `.lua` Include is reported and the enclosing document CONTINUES — the
/// reference logs at severity 2 and falls through (`0x6ee00d` -> `0x6ee012` is unconditional,
/// `eax` never read), so one bad library must not cost the frames declared after it.
#[test]
fn a_raising_lua_include_does_not_take_the_rest_of_the_document() {
let s = UiScript::new().unwrap();
let provider = |path: &str| -> Option<Vec<u8>> {
match path {
"Bad.lua" => Some(b"error('library exploded')".to_vec()),
_ => None,
}
};
let doc = parse(
r#"<Ui>
<Include file="Bad.lua"/>
<Frame name="AfterTheBadInclude"/>
</Ui>"#,
);
let report = load(&s, &doc, &provider);
assert_eq!(
report.errors.len(),
1,
"the raise is reported: {:?}",
report.errors
);
assert!(report.errors[0].contains("Bad.lua"));
assert!(
s.eval::<bool>("return AfterTheBadInclude ~= nil").unwrap(),
"the element after a failed Include must still be built"
);
}
/// `<Include>` resolved through an in-memory provider closure.
#[test]
fn include_resolves_through_provider() {
@@ -968,3 +968,57 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
s.run("DsTex:SetDesaturated(nil) DsTex:SetDesaturated(false)")
.unwrap();
}
/// `UnitCreatureType(unit)` — `0x51a280`'s three-stage resolver, of which we model stages 2 and 3.
///
/// The load-bearing assertion is the **player** one. `0x605570` falls through the (never populated)
/// creature record to a `ChrRaces.dbc` col-9 lookup that is **7 for all nine shipped races**, and
/// `CreatureType[7]` is `"Humanoid"` — so a player answers a word, not nil. Reading only the
/// creature record, which is what our data alone suggested, would have been wrong for every
/// `UnitCreatureType("player")` call in the corpus.
#[test]
fn unit_creature_type_answers_the_record_then_falls_back_to_humanoid() {
let mut s = script();
s.set_unit(
"target",
Some(crate::script::UnitState {
exists: true,
creature_type_name: Some("Beast".into()),
..Default::default()
}),
);
s.set_unit(
"player",
Some(crate::script::UnitState {
exists: true,
is_player: true,
..Default::default()
}),
);
// Stage 2: a creature's cached record wins.
assert_eq!(
s.eval::<String>(r#"return UnitCreatureType("target")"#)
.unwrap(),
"Beast"
);
// Stage 3: a player has no record and falls through to race -> Humanoid.
assert_eq!(
s.eval::<String>(r#"return UnitCreatureType("player")"#)
.unwrap(),
"Humanoid"
);
// An unresolved TOKEN is nil — not a raise.
assert!(s
.eval::<bool>(r#"return UnitCreatureType("party4") == nil"#)
.unwrap());
// A non-string ARGUMENT is a raise, which is a different failure from the nil above:
// `lua_isstring` gates it and `luaL_error` longjmps, so a missing arg abandons the statement.
let err = s
.run("UnitCreatureType()")
.expect_err("a missing arg must raise");
assert!(
format!("{err}").contains("Usage: UnitCreatureType"),
"got {err}"
);
}
@@ -477,6 +477,65 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UnitCreatureType(unit) → **1 string, or nil** (`0x51a280`), the sibling of
// `UnitCreatureFamily 0x51a310`. Adjacent addresses and the same shape — but a DIFFERENT
// column: this reads `CreatureType.dbc` col `1 + locale`, the family reads col `8 + locale`.
// "Adjacent so probably identical" was right about the shape and wrong about the map.
//
// **A three-stage resolver** (`0x605570`), with no class or typemask gate anywhere — its only
// three tests are bounds checks:
// 1. **shapeshift override** — `SpellShapeshiftForm.dbc` col 12, keyed by field 138
// `UNIT_FIELD_BYTES_1` byte 2, taken only if signed `> 0` (`0x60559a jg`), so `0` **and
// the `-1` sentinel** fall through;
// 2. **the cached creature record** `[unit+0xb30]+0x18`, returned **unvalidated** — this is
// what [`UnitState::creature_type_name`] carries;
// 3. **race fallback** — `ChrRaces.dbc` col 9, keyed by field 36 `UNIT_FIELD_BYTES_0` byte 0.
//
// Stage 3 is why **a PLAYER answers `"Humanoid"`, not nil**: `ChrRaces` col 9 is **7 for all
// nine shipped race rows** and `CreatureType[7]` is `"Humanoid"`, while `[player+0xb30]` is
// never populated (§5-verified by a 4-hit writer census — the ctor zeroes it, two writers
// early-out on `key==0`, and the third is hard-gated on `OBJECT_FIELD_TYPE == 0x9`, an
// equality a player's `0x19` fails structurally). A creature-record-only reading answers nil
// there, which would have been wrong for every `UnitCreatureType("player")` and every
// `("target")` aimed at a player.
//
// **Stage 1 is NOT modelled, and it is the one divergence to know about.** A druid in Cat,
// Bear, Dire Bear, Travel or Aquatic form — and a shaman in Ghost Wolf — answers `"Beast"` on
// the reference and `"Humanoid"` here. Tree Form, Moonkin, Battle Stance, Shadowform and
// Spirit of Redemption carry col 12 = `-1` and fall through to race anyway, so those already
// agree. We hold the active form's spell id and name ([`super::super::ShapeshiftFormView`])
// but **not the form INDEX the DBC is keyed by**, and matching on a localised form name would
// be inventing a mechanism the client does not use.
//
// The argument gate is real, and differs from the two boolean siblings: arg1 goes through
// `lua_isstring` (`0x6f3510`) and a miss calls `luaL_error 0x6f4940`, which **longjmps** — so a
// non-string, including a MISSING argument, abandons the caller's statement. An unresolved
// *token* is a different thing entirely and answers nil (`0x51a2b8`).
//
// Demand: 6 distinct corpus addons over 11 files.
g.set(
"UnitCreatureType",
lua.create_function(|lua, token: Value| {
let token = match &token {
Value::String(s) => Some(s.to_str()?.to_string()),
// `lua_isstring` coerces a number, so a numeric token is accepted and simply
// resolves to nothing.
Value::Number(_) | Value::Integer(_) => Some(String::new()),
_ => return Err(mlua::Error::runtime("Usage: UnitCreatureType(\"unit\")")),
};
let word = with_unit(lua, &token, None, |u| {
u.creature_type_name
.clone()
// Stage 3, collapsed: every player race maps to CreatureType 7.
.or_else(|| u.is_player.then(|| "Humanoid".to_string()))
});
match word {
Some(w) => Ok(Value::String(lua.create_string(&w)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// UnitClassification(unit) → "normal" | "elite" | "rareelite" | "worldboss" | "rare" (decision
// 0782, byte-verified `0x516d90`): a plain table index by the gated rank, and it answers a
// STRING for every input — never nil. An absent snapshot deliberately reports "normal" rather