cooldowns survive a relog, the MOTD prints, and lost channels come back

- a cooldown still running when you log out and back in keeps sweeping instead of vanishing, and another character no longer inherits the previous session's cooldowns (2116)
- the server's Welcome and Patch 1.12 lines print at login in system yellow, and a character whose General, Trade and LocalDefense had gone silent for good gets them back at the next login (2119, 2120)
- the stock Interface Options window loads hidden beside ours, so pfUI's options skin finds the real thing; the Auto-join Guild Recruitment Channel row has a real store, and a /reload no longer trips on its camera dropdowns (2115)
- the combat log words a power gain on the display scale, so MikScrollingBattleText shows +1 Rage where it showed +10; a 1.12 predicate answers 1 or nil rather than a Lua boolean, the test ColorPickerPlus makes (2117, 2118)
- a Lua error quotes a program element with a backquote the way 5.0 does, so AceLibrary's argument checks report their caller instead of raising inside the error path (2122)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:50:37 +07:00
co-authored by Claude Fable 5.1
parent 321bcbb393
commit c343d236e9
62 changed files with 2473 additions and 704 deletions
+137 -311
View File
@@ -191,80 +191,57 @@
</Font>
<Script><![CDATA[
-- ── The reference's own option table, verbatim (stock UIOptionsFrame.lua l.1-84; 1938) ──
-- Stock MultiActionBars.lua:10 writes five rows of this table from MultiActionBarFrame_OnLoad
-- ("Hack to get around load order dependencies": the reference's toc loads UIOptionsFrame.xml
-- at l.21, before MultiActionBars.xml at l.39, so the MultiBar*_IsVisible funcs named at
-- l.55-59 are still nil when the table is declared, exactly as they are here). Our rows
-- below keep their own store (OptionsRow_Get's three kinds) and do not read this table; it
-- is published because the reference's bar file indexes it at LOAD, and this file is the
-- options window — the table's home — now seated at the reference's position in the
-- manifest. It goes with the window when UIOptionsFrame.xml migrates.
-- The two Action Bars uvars the reference's own options window inits in UIOptionsFrame_Init
-- (stock UIOptionsFrame.lua l.102-107), at file scope here because the rows below capture the
-- value at their OnLoad as the registered default. LOCK_ACTIONBAR is read by stock
-- ActionBarFrame.xml's drag guards and PetActionBarFrame.lua:270/278 (ours PetActionBar.xml);
-- ALWAYS_SHOW_MULTIBARS by stock MultiActionBar_UpdateGridVisibility. Both lived in the bar
-- files until 1938 took those stock.
LOCK_ACTIONBAR = "0"
RegisterForSave("LOCK_ACTIONBAR")
ALWAYS_SHOW_MULTIBARS = "0"
RegisterForSave("ALWAYS_SHOW_MULTIBARS")
-- The quest pair's two flags, likewise UIOptionsFrame_Init's (stock l.110-111, l.122-123),
-- here since the quest windows went stock (1944). QUEST_FADING_DISABLE is the reference's
-- "0". AUTO_QUEST_WATCH is the string "1" ON PURPOSE (decision 1136): the reference assigns
-- the NUMBER 1 while every consumer — stock QuestLogFrame.lua:64/68 and the checkbox's own
-- load — compares against the string, so on a fresh 1.12 install the row paints unchecked
-- and the auto-watch is dead until the box is clicked; the string ships the advertised
-- default as the real one. A recorded deviation (1804's rule), not a quiet one.
-- "Show Detailed Tooltips" — ref UIOptionsFrame.lua l.100-101, inside UIOptionsFrame_Init.
-- A FrameXML uvar, not a CVar, and 1.12 ships it ON: the explanatory second line IS the
-- default reference tooltip, not an opt-in (0661). It lived in our GameTooltip.xml until
-- the tooltip went stock (1968); the reference keeps it here, with the Interface page.
SHOW_NEWBIE_TIPS = "1"
RegisterForSave("SHOW_NEWBIE_TIPS")
-- "Hide World PvP Objectives" — ref UIOptionsFrame.lua's `HIDE_OUTDOOR_WORLD_STATE = "0"`,
-- the gate the stock WorldStateAlwaysUpFrame_Update opens the outdoor readout on
-- (`== "0" or instanceType == "pvp"`). Here since that frame went stock (1972); a nil
-- reads as hidden, which is how the tower counters would vanish outdoors.
HIDE_OUTDOOR_WORLD_STATE = "0"
RegisterForSave("HIDE_OUTDOOR_WORLD_STATE")
QUEST_FADING_DISABLE = "0"
RegisterForSave("QUEST_FADING_DISABLE")
-- ══ WHAT THIS FILE NO LONGER DECLARES, AND WHERE IT COMES FROM NOW (decision 2115) ══════
--
-- benilla's options window is THIS file and it is the one the player opens. But the
-- reference's own `UIOptionsFrame.xml` is now loaded too — hidden, off the player's chain,
-- at the manifest's l.21 seat above this row — so that a third-party addon reaching for
-- `UIOptionsFrame`, `UIOptionsFrameCheckButton42` or `UIOptionsFrame_Load()` finds the real
-- thing instead of an alias onto us. Three things this file used to carry are the stock
-- file's, and are gone from here rather than duplicated:
--
-- · **The 38 options uvars.** `UIOptionsFrame_Init` (stock UIOptionsFrame.lua l.93-185)
-- assigns every one its factory value and `RegisterForSave`s it — SIMPLE_CHAT,
-- CHAT_LOCKED, REMOVE_CHAT_DELAY, SHOW_NEWBIE_TIPS, LOCK_ACTIONBAR,
-- SHOW_BUFF_DURATIONS, ALWAYS_SHOW_MULTIBARS, SHOW_PARTY_PETS, QUEST_FADING_DISABLE,
-- SHOW_PARTY_BACKGROUND, HIDE_PARTY_INTERFACE, SHOW_TARGET_OF_TARGET(+_STATE),
-- HIDE_OUTDOOR_WORLD_STATE, AUTO_QUEST_WATCH, the fourteen COMBAT_TEXT_* rows and
-- COMBAT_TEXT_FLOAT_MODE, plus NAMEPLATES_ON/FRIENDNAMEPLATES_ON. Every value we used
-- to write here was already the reference's (1804 made them so), so this is a pure
-- de-duplication and the DEFAULT IS NOW THE REFERENCE'S BY CONSTRUCTION: our rows read
-- the global at their OnLoad (`OptionsGlobalRow_OnLoad`'s `udefault`), and the stock
-- file has assigned it long before, so Defaults still walks back to the shipped value.
-- Five it declares that we never did come free: SIMPLE_CHAT, SHOW_PARTY_PETS,
-- SHOW_PARTY_BACKGROUND, HIDE_PARTY_INTERFACE, and the nameplate pair's save rows.
--
-- · **`UIOptionsFrameCheckButtons`.** The reference's option table, whose home is that
-- file. Stock `MultiActionBars.lua:10` writes five rows into it at ITS load ("Hack to
-- get around load order dependencies"), which is why the stock window's row must sit
-- above MultiActionBars.xml in benilla.toc — it does, at l.21 exactly as the
-- reference's toc has it. Our rows never read this table; they keep their own store
-- (`OptionsRow_Get`'s three kinds).
--
-- · **`UpdateNameplates`.** `UIOptionsFrame.lua` l.768-780 — the reference's own home
-- for it, and the four verbs it calls (Show/HideNameplates, Show/HideFriendNameplates)
-- are ours in `script/cvars.rs`, unchanged.
--
-- ── THE ONE ASSIGNMENT THAT STAYS, AND WHY IT IS AN OVERRIDE ────────────────────────────
-- `AUTO_QUEST_WATCH` is the string `"1"` ON PURPOSE (decision 1136): the reference assigns
-- the NUMBER 1 (`UIOptionsFrame.lua` l.122) while every consumer — stock
-- `QuestLogFrame.lua:64/68` and the checkbox's own load — compares against the STRING, and
-- in Lua 5.0 `1 ~= "1"`. So on a fresh 1.12 install the box paints unchecked and the
-- advertised auto-watch is dead until the player clicks it. We ship the string so the
-- advertised default is the real one — a recorded deviation under 1804's rule, not a quiet
-- one.
--
-- **This file loads AFTER the stock one** (manifest l.21 vs this window's seat before
-- GameMenuFrame), so this line runs after `UIOptionsFrame_Init` and wins. It is a
-- re-assignment, not a declaration: the `RegisterForSave` is the stock file's, and a second
-- one would be redundant — but it is kept, because `RegisterForSave` is idempotent and a
-- reader of this line should not have to know the stock file registered it to know the
-- global is persisted.
AUTO_QUEST_WATCH = "1"
RegisterForSave("AUTO_QUEST_WATCH")
-- Combat text uvars (ref UIOptionsFrame.lua l.135-152, verbatim). They lived in our
-- CombatText.xml while that file stood in for the addon; the addon is the reference's
-- own `Blizzard_CombatText` off the chain now, LoadOnDemand as the reference ships it,
-- so its option family's defaults sit where the reference keeps them (1964).
SHOW_COMBAT_TEXT = "0"
RegisterForSave("SHOW_COMBAT_TEXT")
COMBAT_TEXT_SHOW_LOW_HEALTH_MANA = "1"
RegisterForSave("COMBAT_TEXT_SHOW_LOW_HEALTH_MANA")
COMBAT_TEXT_SHOW_AURAS = "1"
RegisterForSave("COMBAT_TEXT_SHOW_AURAS")
COMBAT_TEXT_SHOW_AURA_FADE = "0"
RegisterForSave("COMBAT_TEXT_SHOW_AURA_FADE")
COMBAT_TEXT_SHOW_COMBAT_STATE = "1"
RegisterForSave("COMBAT_TEXT_SHOW_COMBAT_STATE")
COMBAT_TEXT_SHOW_DODGE_PARRY_MISS = "0"
RegisterForSave("COMBAT_TEXT_SHOW_DODGE_PARRY_MISS")
COMBAT_TEXT_SHOW_RESISTANCES = "0"
RegisterForSave("COMBAT_TEXT_SHOW_RESISTANCES")
COMBAT_TEXT_SHOW_REPUTATION = "0"
RegisterForSave("COMBAT_TEXT_SHOW_REPUTATION")
COMBAT_TEXT_SHOW_REACTIVES = "0"
RegisterForSave("COMBAT_TEXT_SHOW_REACTIVES")
COMBAT_TEXT_SHOW_FRIENDLY_NAMES = "0"
RegisterForSave("COMBAT_TEXT_SHOW_FRIENDLY_NAMES")
COMBAT_TEXT_SHOW_COMBO_POINTS = "0"
RegisterForSave("COMBAT_TEXT_SHOW_COMBO_POINTS")
COMBAT_TEXT_SHOW_MANA = "0"
RegisterForSave("COMBAT_TEXT_SHOW_MANA")
COMBAT_TEXT_FLOAT_MODE = "1"
RegisterForSave("COMBAT_TEXT_FLOAT_MODE")
COMBAT_TEXT_SHOW_HONOR_GAINED = "1"
RegisterForSave("COMBAT_TEXT_SHOW_HONOR_GAINED")
-- The family's apply, ref UIOptionsFrame_Save l.371-376 verbatim: the addon is
-- LoadOnDemand, so turning the master on loads it, and the update runs only once it is
@@ -277,15 +254,6 @@
CombatText_UpdateDisplayedMessages()
end
end
-- CHAT_LOCKED / REMOVE_CHAT_DELAY — the chat window's two uvars, the reference's own
-- UIOptionsFrame.lua l.96-99 defaults ("0" and "0"), read every frame by
-- FloatingChatFrame.lua's FCF_Get_ChatLocked and FCF_OnUpdate since the chat window went
-- stock (1948). Declared here because this is where the reference declares them.
CHAT_LOCKED = "0"
RegisterForSave("CHAT_LOCKED")
REMOVE_CHAT_DELAY = "0"
RegisterForSave("REMOVE_CHAT_DELAY")
-- The Remove Chat Hover Delay row's applyFunc: the reference's own line
-- (UIOptionsFrame.lua l.216, `SetChatMouseOverDelay(REMOVE_CHAT_DELAY)`) under the name the
-- row resolves at apply time. `SetChatMouseOverDelay` is FloatingChatFrame.lua's.
@@ -320,123 +288,6 @@
end
end
UIOptionsFrameCheckButtons = { };
------------------- Basic Options -------------------
-- Controls
UIOptionsFrameCheckButtons["INVERT_MOUSE"] = { index = 1, cvar = "mouseInvertPitch" };
-- General
UIOptionsFrameCheckButtons["CLICK_TO_MOVE"] = { index = 6, cvar = "autointeract" };
UIOptionsFrameCheckButtons["GAMEFIELD_DESELECT_TEXT"] = { index = 45, cvar = "deselectOnClick"};
UIOptionsFrameCheckButtons["ASSIST_ATTACK"] = { index = 3, cvar = "assistAttack" };
UIOptionsFrameCheckButtons["CLEAR_AFK"] = { index = 16, cvar = "autoClearAFK" };
UIOptionsFrameCheckButtons["BLOCK_TRADES"] = { index = 14, cvar = "BlockTrades" };
UIOptionsFrameCheckButtons["AUTO_SELF_CAST_TEXT"] = { index = 61, cvar = "autoSelfCast"};
-- Display
UIOptionsFrameCheckButtons["USE_UBERTOOLTIPS"] = { index = 12, cvar = "UberTooltips" };
UIOptionsFrameCheckButtons["STATUS_BAR_TEXT"] = { index = 2, cvar = "statusBarText" };
UIOptionsFrameCheckButtons["PROFANITY_FILTER"] = { index = 5, cvar = "profanityFilter" };
UIOptionsFrameCheckButtons["SHOW_CLOAK"] = { index = 31, func = ShowingCloak, setFunc = ShowCloak};
UIOptionsFrameCheckButtons["SHOW_HELM"] = { index = 20, func = ShowingHelm , setFunc = ShowHelm };
UIOptionsFrameCheckButtons["SHOW_QUEST_FADING_TEXT"] = { index = 42, uvar = "QUEST_FADING_DISABLE", default = "0"};
UIOptionsFrameCheckButtons["SHOW_BUFF_DURATION_TEXT"] = { index = 39, uvar = "SHOW_BUFF_DURATIONS", default = "0"};
UIOptionsFrameCheckButtons["SHOW_PLAYER_NAMES"] = { index = 21, cvar = "UnitNamePlayer"};
UIOptionsFrameCheckButtons["SHOW_GUILD_NAMES"] = { index = 22, cvar = "UnitNamePlayerGuild"};
UIOptionsFrameCheckButtons["SHOW_PLAYER_TITLES"] = { index = 23, cvar = "UnitNamePlayerPVPTitle"};
UIOptionsFrameCheckButtons["SHOW_NPC_NAMES"] = { index = 30, cvar = "UnitNameNPC"};
UIOptionsFrameCheckButtons["SHOW_OWN_NAME"] = { index = 67, cvar = "UnitNameOwn"};
UIOptionsFrameCheckButtons["SHOW_PARTY_BACKGROUND_TEXT"] = { index = 43, uvar = "SHOW_PARTY_BACKGROUND"};
UIOptionsFrameCheckButtons["HIDE_OUTDOOR_WORLD_STATE_TEXT"] = { index = 62, uvar = "HIDE_OUTDOOR_WORLD_STATE", default = "0"};
UIOptionsFrameCheckButtons["AUTO_QUEST_WATCH_TEXT"] = { index = 66, uvar = "AUTO_QUEST_WATCH", default = "1"};
-- Camera Controls
UIOptionsFrameCheckButtons["FOLLOW_TERRAIN"] = { index = 24, cvar = "cameraTerrainTilt"};
UIOptionsFrameCheckButtons["HEAD_BOB"] = { index = 26, cvar = "cameraBobbing"};
UIOptionsFrameCheckButtons["WATER_COLLISION"] = { index = 27, cvar = "cameraWaterCollision"};
UIOptionsFrameCheckButtons["SMART_PIVOT"] = { index = 15, cvar = "cameraPivot" };
-- Help
UIOptionsFrameCheckButtons["SHOW_TUTORIALS"] = { index = 28};
UIOptionsFrameCheckButtons["SHOW_NEWBIE_TIPS_TEXT"] = { index = 29, uvar = "SHOW_NEWBIE_TIPS"};
UIOptionsFrameCheckButtons["SHOW_TIPOFTHEDAY_TEXT"] = { index = 44, cvar = "showGameTips"};
------------------- Advanced Options -------------------
-- Chat
UIOptionsFrameCheckButtons["SIMPLE_CHAT_TEXT"] = { index = 7, uvar = "SIMPLE_CHAT" , default = "1"};
UIOptionsFrameCheckButtons["CHAT_LOCKED_TEXT"] = { index = 8, uvar = "CHAT_LOCKED" , default = "1"};
UIOptionsFrameCheckButtons["GUILDMEMBER_ALERT"] = { index = 13, cvar = "guildMemberNotify" };
UIOptionsFrameCheckButtons["REMOVE_CHAT_DELAY_TEXT"] = { index = 18, uvar = "REMOVE_CHAT_DELAY", default = "0"};
UIOptionsFrameCheckButtons["CHAT_BUBBLES_TEXT"] = { index = 37, cvar = "ChatBubbles"};
UIOptionsFrameCheckButtons["PARTY_CHAT_BUBBLES_TEXT"] = { index = 38, cvar = "ChatBubblesParty"};
UIOptionsFrameCheckButtons["SHOW_LOOT_SPAM"] = { index = 46, cvar = "showLootSpam"};
UIOptionsFrameCheckButtons["AUTO_JOIN_GUILD_CHANNEL"] = { index = 51};
UIOptionsFrameCheckButtons["DISABLE_SPAM_FILTER"] = { index = 68, cvar = "spamFilter"};
-- Action Bars
UIOptionsFrameCheckButtons["LOCK_ACTIONBAR_TEXT"] = { index = 32, uvar = "LOCK_ACTIONBAR", default = "0"};
UIOptionsFrameCheckButtons["SHOW_MULTIBAR1_TEXT"] = { index = 33, func = MultiBar1_IsVisible, setFunc = Multibar_EmptyFunc};
UIOptionsFrameCheckButtons["SHOW_MULTIBAR2_TEXT"] = { index = 34, func = MultiBar2_IsVisible, setFunc = Multibar_EmptyFunc};
UIOptionsFrameCheckButtons["SHOW_MULTIBAR3_TEXT"] = { index = 35, func = MultiBar3_IsVisible, setFunc = Multibar_EmptyFunc};
UIOptionsFrameCheckButtons["SHOW_MULTIBAR4_TEXT"] = { index = 36, func = MultiBar4_IsVisible, setFunc = Multibar_EmptyFunc};
UIOptionsFrameCheckButtons["ALWAYS_SHOW_MULTIBARS_TEXT"] = { index = 40, func = MultibarGrid_IsVisible, setFunc = Multibar_EmptyFunc};
-- Raid & Party Options
UIOptionsFrameCheckButtons["HIDE_PARTY_INTERFACE_TEXT"] = { index = 47, uvar = "HIDE_PARTY_INTERFACE" , default = "0"};
UIOptionsFrameCheckButtons["SHOW_DISPELLABLE_DEBUFFS_TEXT"] = { index = 48, uvar = "SHOW_DISPELLABLE_DEBUFFS" , default = "1"};
UIOptionsFrameCheckButtons["SHOW_CASTABLE_BUFFS_TEXT"] = { index = 49, uvar = "SHOW_CASTABLE_BUFFS" , default = "1"};
UIOptionsFrameCheckButtons["SHOW_TARGET_OF_TARGET_TEXT"] = { index = 50, uvar = "SHOW_TARGET_OF_TARGET" , default = "0"};
UIOptionsFrameCheckButtons["SHOW_PARTY_PETS_TEXT"] = { index = 41, uvar = "SHOW_PARTY_PETS", default = "1"};
-- Combat Text
UIOptionsFrameCheckButtons["SHOW_COMBAT_TEXT_TEXT"] = { index = 52, uvar = "SHOW_COMBAT_TEXT" , default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_LOW_HEALTH_MANA_TEXT"] = { index = 53, uvar = "COMBAT_TEXT_SHOW_LOW_HEALTH_MANA" , default = "1"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_AURAS_TEXT"] = { index = 54, uvar = "COMBAT_TEXT_SHOW_AURAS" , default = "1"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_AURA_FADE_TEXT"] = { index = 55, uvar = "COMBAT_TEXT_SHOW_AURA_FADE" , default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_COMBAT_STATE_TEXT"] = { index = 56, uvar = "COMBAT_TEXT_SHOW_COMBAT_STATE" , default = "1"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_DODGE_PARRY_MISS_TEXT"] = { index = 57, uvar = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_RESISTANCES_TEXT"] = { index = 58, uvar = "COMBAT_TEXT_SHOW_RESISTANCES", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_REPUTATION_TEXT"] = { index = 60, uvar = "COMBAT_TEXT_SHOW_REPUTATION", default = "0"};
UIOptionsFrameCheckButtons["SHOW_DAMAGE_TEXT"] = { index = 19, cvar = "CombatDamage"};
UIOptionsFrameCheckButtons["LOG_PERIODIC_EFFECTS"] = { index = 11, cvar = "CombatLogPeriodicSpells" };
UIOptionsFrameCheckButtons["SHOW_PET_MELEE_DAMAGE"] = { index = 9, cvar = "PetMeleeDamage" };
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_REACTIVES_TEXT"] = { index = 63, uvar = "COMBAT_TEXT_SHOW_REACTIVES", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_FRIENDLY_NAMES_TEXT"] = { index = 64, uvar = "COMBAT_TEXT_SHOW_FRIENDLY_NAMES", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_COMBO_POINTS_TEXT"] = { index = 65, uvar = "COMBAT_TEXT_SHOW_COMBO_POINTS", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_MANA_TEXT"] = { index = 59, uvar = "COMBAT_TEXT_SHOW_MANA", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_HONOR_GAINED_TEXT"] = { index = 69, uvar = "COMBAT_TEXT_SHOW_HONOR_GAINED", default = "1"};
-- ── SHOW_BUFF_DURATIONS: the buff bar's timer switch (decision 1139) ──────────────────
--
-- The reference declares this in `UIOptionsFrame.lua` (l.21/104), whose `default` is
-- `"0"`. We have no counterpart to that file, and the row that drives it is on THIS
-- window's Interface page — so this is its home, and 1751's eighteenth window is what
-- moved it: it lived in our BuffFrame.xml, and the buff bar is the reference's own file
-- now, which reads the global and declares nothing.
--
-- **`"0"` — the reference's own, since 1804.** It shipped `"1"` from 0255, which put the
-- durations-shown geometry in on the director's call, through 1139, which turned it into a
-- setting without re-weighing what it ships as. This assignment is the ONLY statement of
-- the default: the saved chunk executes over it at load (1128), and the Interface page's
-- row captures it as its Defaults value at OnLoad, which runs first — so Defaults now
-- walks back to the reference's table because this line IS the reference's table.
--
-- Read at CALL time by `BuffButton_Update` / `BuffButtons_UpdatePositions` /
-- `BuffFrame_UpdateDuration`, so this file sitting far below the bar does not bind.
SHOW_BUFF_DURATIONS = "0"
RegisterForSave("SHOW_BUFF_DURATIONS")
-- The target-of-target pair, at the reference's own values and in the reference's own home
-- (`UIOptionsFrame.lua` l.116-119, where both sit in the uvar defaults block and both are
-- RegisterForSave'd). The switch ships OFF and the state ships "5".
--
-- These lived in our `UnitFrames.xml` file scope until the unit-frame kit went to the
-- chain — a squatter, and its own note said so. The reference declares them beside the
-- other party/unit uvars here, and `TargetFrame.lua:504` reads the switch at CALL time
-- (`TargetofTarget_Update`), so this file sitting far below the frames does not bind.
SHOW_TARGET_OF_TARGET = "0"
SHOW_TARGET_OF_TARGET_STATE = "5"
RegisterForSave("SHOW_TARGET_OF_TARGET")
RegisterForSave("SHOW_TARGET_OF_TARGET_STATE")
-- The static category tree (era Game tab, Classic tocs), in list order. Keys are the row
-- name suffixes; the visible label doubles as the page title (the era does the same —
@@ -455,39 +306,6 @@
-- reference at typical resolutions reads nearer two-thirds.
ERA_WINDOW_SCALE = 0.78
-- ── UpdateNameplates (ref UIOptionsFrame.lua l.768-780, verbatim) ────────────────────
-- Its reference home is `UIOptionsFrame.lua` and this file is our stand-in for that window
-- (0950/0992), so the function belongs here rather than beside its caller.
--
-- **Decision 1878 recorded this as "a missing ENGINE verb, and 1203 says build it or leave
-- it loudly absent", and named it the thing blocking the UIParent keystone. It is neither.**
-- It is FrameXML — `UIOptionsFrame.lua:768`, absent from wow-re's binding-shape table — and
-- every verb it calls is already ours: `ShowNameplates`/`HideNameplates`/
-- `ShowFriendNameplates`/`HideFriendNameplates` are the four CVar-backed bindings in
-- `script/cvars.rs`, all argc=0 arity=0, matching the reference's shapes.
--
-- `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` are `RegisterForSave` globals the reference drives
-- from the keybinding alone (1.12's own options UI has no nameplate row). Nil is a normal
-- state for them and the else-branches are the reference's own answer to it, so this needs
-- no seeding — which is why the whole thing is twelve lines rather than a build.
--
-- The reference's odd second arm is kept as it stands: on FRIENDNAMEPLATES_ON it calls
-- Show and then Hide, which is a no-op in the real client too. Transcribed, not corrected;
-- fixing a reference bug here would be inventing a difference.
function UpdateNameplates()
if ( NAMEPLATES_ON ) then
ShowNameplates()
else
HideNameplates()
end
if ( FRIENDNAMEPLATES_ON ) then
ShowFriendNameplates()
HideFriendNameplates()
else
HideFriendNameplates()
end
end
function OptionsFrame_UpdateScale()
-- The era panel is registered `checkFit = 1`: the panel manager also shrinks it to
-- fit a small screen (UIPanelUpdateScaleForFit: min over width/height ratios
@@ -982,9 +800,15 @@
-- verbs). It is unbuilt because nobody has asked for it, not because it
-- would be inert — and the per-window lock the tab menu carries is the one
-- a player actually reaches for. Ships when it is wanted
-- · 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
-- · 51 Auto-join Guild Recruitment Channel — **this note used to say the row is
-- inert in the reference too, and that was wrong** (corrected by 2115; it is
-- exactly the mistake 2077 corrected for SHOW_TUTORIALS). The table entry is a
-- bare `{ index = 51 }`, but the store is not in the TABLE — it is in the three
-- special-case arms `UIOptionsFrame_Load` (l.243-248, `GetGuildRecruitmentMode`),
-- `_Save` (l.326-331, `SetGuildRecruitmentMode`) and `_SetDefaults` (l.649) keep
-- for the key. Both verbs are ours now (2115), over the chat cache's
-- `OPTION_GUILD_RECRUITMENT_CHANNEL` latch. The row is unshipped here because
-- nobody has asked for it, not because it would be inert
-- 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
@@ -1142,52 +966,42 @@
row.fdefault = default
end
-- The other end of `applyFunc`, and the reason a saved "off" used to come back on
-- (decision 1139). A CVar row needs nothing here — the host reads its own table at boot.
-- A GLOBAL row does: the whole UI's XML runs BEFORE the saved chunk executes over it
-- (1128's load order), so every file-scope consumer already ran against the SHIPPED
-- default — CombatText_OnLoad armed six event registrations from SHOW_COMBAT_TEXT = "1",
-- BuffFrame seated its rows from the durations-shown geometry — and the saved value
-- landing afterwards changed the variable while leaving the world it configured alone.
-- ── THE LOAD-TIME LADDER IS THE REFERENCE'S OWN AGAIN (decision 2115) ──────────────
--
-- 1.12 closes this with a hand-written ladder in UIOptionsFrame's VARIABLES_LOADED arm
-- (l.204-220, "Option specific function calls": BuffButtons_UpdatePositions, the
-- SIMPLE_CHAT/CHAT_LOCKED arms, MultiActionBar_ShowAllGrids). We already hold each side
-- effect on its own row, so the ladder is a walk: run every distinct applyFunc once, in
-- no particular order (they are independent by construction — each one re-derives one
-- feature's state from one global). A row whose defining file was not loaded has a nil
-- applyFunc and is skipped, which is what keeps a partial test harness honest.
-- The multibar grid switch's load arm — stock UIOptionsFrame.lua l.218-220 verbatim.
function OptionsFrame_MultibarGrid_Load()
if ( ALWAYS_SHOW_MULTIBARS == "1" or ALWAYS_SHOW_MULTIBARS == 1 ) then
MultiActionBar_ShowAllGrids()
end
end
function OptionsFrame_ApplySavedSettings()
-- ref UIOptionsFrame_OnEvent's VARIABLES_LOADED arm, l.224-226: the combat text
-- addon loads at login when the saved master is on (1964).
if ( SHOW_COMBAT_TEXT == "1" ) then
UIParentLoadAddOn("Blizzard_CombatText")
end
local ran = {}
for key, rows in pairs(OPTIONS_PAGE_ROWS) do
for _, rkey in ipairs(rows) do
local row = getglobal("OptionsFrameContainerBody" .. key .. rkey)
-- `applyFunc` is a NAME (see OptionsPage_Apply): resolved here, when the
-- definer has long since loaded; a name with nothing behind it is skipped. A
-- row whose LOAD-time side effect differs from its click's names it as
-- `loadFunc` (the multibar grid switch, below).
local name = row and (row.loadFunc or row.applyFunc)
local apply = name
if type(name) == "string" then apply = getglobal(name) end
if apply and not ran[name] then
ran[name] = 1
apply()
end
end
end
end
-- The problem it solves is real and unchanged (decision 1139): the whole UI's XML runs
-- BEFORE the saved chunk executes over it (1128's load order), so every file-scope
-- consumer of a `RegisterForSave`'d global already ran against the SHIPPED default, and
-- the saved value landing afterwards changes the variable while leaving the world it
-- configured alone. A CVar row needs nothing — the host reads its own table at boot.
--
-- 1.12 closes it with a hand-written ladder in `UIOptionsFrame_OnEvent`'s VARIABLES_LOADED
-- arm (UIOptionsFrame.lua l.193-227): `BuffButtons_UpdatePositions`, the SIMPLE_CHAT and
-- CHAT_LOCKED arms, `SetChatMouseOverDelay(REMOVE_CHAT_DELAY)`,
-- `MultiActionBar_ShowAllGrids`, `RaidOptionsFrame_UpdatePartyFrames`, and
-- `UIParentLoadAddOn("Blizzard_CombatText")`. **That arm runs here now**, off the player's
-- chain, because the stock file is loaded (hidden) at the manifest's l.21 seat — so this
-- window used to re-express a ladder that did not exist and now would be a SECOND copy of
-- one that does.
--
-- So the walk is gone, and with it `OptionsFrame_ApplySavedSettings`, this window's
-- VARIABLES_LOADED registration, and the multibar grid row's `loadFunc`. What the walk did
-- that the stock arm does not, checked one by one rather than assumed:
--
-- · `BuffButtons_UpdatePositions`, `SetChatMouseOverDelay`, `MultiActionBar_ShowAllGrids`
-- and `UIParentLoadAddOn("Blizzard_CombatText")` — the stock arm does all four, and it
-- runs FIRST (its frame registers at the l.21 seat, ours would register 30 rows later).
-- · `CombatText_UpdateDisplayedMessages` — the stock arm does not call it, and does not
-- need to: `Blizzard_CombatText`'s own `CombatText_OnLoad` calls it as its first act
-- (Blizzard_CombatText.lua l.81-84), and the addon loads INSIDE the stock arm, by
-- which time the saved uvars are already in place.
-- · `TargetofTarget_Update` — the stock arm does not call it either, and at
-- VARIABLES_LOADED there is no target, so `TargetFrame.lua:504`'s guard takes the else
-- branch and hides a frame that is already hidden. The row keeps it as its `applyFunc`,
-- which is the click behaviour and the only thing it was ever needed for.
--
-- The property the walk carried that still matters is `OptionsRow_Set`'s, and it stays
-- there: an `applyFunc` is a NAME, resolved when the row is applied, so a row whose
-- defining file a test harness never loaded applies nothing instead of raising.
-- A slider row's own range and readout, when the template's 0..1 / 5% / percent (the
-- volume shape) isn't it — called after OptionsRow_OnLoad in the row's OnLoad. The
@@ -3215,14 +3029,15 @@
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "ALWAYS_SHOW_MULTIBARS",
"Always Show ActionBars", "OPTION_TOOLTIP_ALWAYS_SHOW_MULTIBARS")
-- Two arms, as the reference has them: a CLICK runs
-- MultiActionBar_UpdateGridVisibility (UIOptionsFrame.xml:683);
-- the LOAD arm only ever SHOWS (UIOptionsFrame.lua:218-220),
-- because every hide is a counted decrement on each well
-- (ActionButton_HideGrid) and a load-time hide against wells
-- nobody opened would push the count negative.
-- A CLICK runs MultiActionBar_UpdateGridVisibility, the
-- reference's own click arm (UIOptionsFrame.xml:683).
-- The LOAD arm — which only ever SHOWS, because every hide
-- is a counted decrement on each well (ActionButton_HideGrid)
-- and a load-time hide against wells nobody opened would push
-- the count negative — is the stock file's
-- VARIABLES_LOADED arm now (UIOptionsFrame.lua l.218-220),
-- which is why this row carries no `loadFunc` (2115).
self.applyFunc = "MultiActionBar_UpdateGridVisibility"
self.loadFunc = "OptionsFrame_MultibarGrid_Load"
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowLockActionBar" inherits="OptionsCheckboxRowTemplate">
@@ -4153,17 +3968,6 @@
</Button>
</Frames>
<Scripts>
<!-- The window is where the saved globals get applied, not just edited: the chunk
lands after every file-scope consumer has already run (decision 1139). This is
1.12's own seat for it — UIOptionsFrame registers the same event for the same
reason — and the window is hidden at the time, which is the point: the event is
the UI's, not the panel's. -->
<OnLoad>this:RegisterEvent("VARIABLES_LOADED")</OnLoad>
<OnEvent>
if event == "VARIABLES_LOADED" then
OptionsFrame_ApplySavedSettings()
end
</OnEvent>
<!-- Controls is the default page; a re-opened window keeps its last selection (the
era remembers too — Settings.OpenToCategory's default seat). The scale re-fits
on every show — the era panel manager re-runs checkFit the same way. -->
@@ -4184,21 +3988,43 @@
</OnHide>
</Scripts>
</Frame>
<!-- The reference has three options windows — OptionsFrame (video), SoundOptionsFrame and
UIOptionsFrame (interface) — and ours is the one window for all three (0985), kept ours by
the director's call. The stock files address the three by name: FloatingChatFrame.lua reads
`UIOptionsFrame:IsShown()` every frame (FCF_OnUpdate's hover gate), and the stock micro
row's UpdateMicroButtons reads all three UNGUARDED to decide whether the Main Menu button
is down (MainMenuBarMicroButtons.lua l.47-52; decision 1987). These two aliases are the
adapter: a stock read of either sibling is answered by this window, which is the honest
answer — while ours is up, the reference's question "is an options window open" is yes.
The third alias is the ESC ladder's: the stock `ToggleGameMenu` (UIParent.lua l.1465-1497,
off the chain since 1988) closes an open options window with `OptionsFrameCancel:Click()`,
the reference's Cancel button; ours has a corner X whose click is the same discard-and-hide
(`HideUIPanel(OptionsFrame)`, and OnHide discards the pending page), so that button answers
to the reference's name. BENILLA_ALLOW_OWN_UI=1 -->
<!-- ══ THE TWO SURVIVING ALIASES, AND THE ONE THAT HAD TO GO (decision 2115) ══════════════
The reference has THREE options windows — `OptionsFrame` (video), `SoundOptionsFrame` and
`UIOptionsFrame` (interface) — and benilla's one window answers to the first name outright,
because that is what this frame is called. What happens to the other two is not one
question but two:
**`UIOptionsFrame` is NO LONGER ALIASED, and must never be.** The reference's own
`UIOptionsFrame.xml` is loaded, hidden, at the manifest's l.21 seat, so that name is a real
frame with real children — which is the whole point of 2115: pfUI's
`skins/blizzard/options-interface.lua` does `UIOptionsFrame:SetWidth(1024)` and then walks
`UIOptionsFrameCheckButton1..69`. Under the old alias that `SetWidth` landed on THIS window,
the one the player opens. Nothing of ours may `Show()` the stock frame either; it is
`hidden="true"` in its own file and that is what keeps it off the screen.
**`SoundOptionsFrame` stays aliased**, because we do not load the reference's
`SoundOptionsFrame.xml` at all (2115 says why: nothing in the addon corpus needs it beyond
one optional pfUI skin). It answers exactly two stock consumers, both of which read all
three window names UNGUARDED and both of which are asking "is an options window open":
`MainMenuBarMicroButtons.lua` l.47-52 (`UpdateMicroButtons`, deciding whether the Main Menu
micro button is pushed — decision 1987) and `UIParent.lua:996` (`IsOptionFrameOpen`, on the
path of every window close). While ours is up the honest answer to both is yes, and the
alias is how they get it. `OptionsFrame`, being this frame's own name, answers them too.
**`OptionsFrameCancel` stays**, and it is the ESC ladder's: the stock `ToggleGameMenu`
(UIParent.lua l.1465-1497, off the chain since 1988) closes an open options window with
`OptionsFrame:IsVisible()` → `OptionsFrameCancel:Click()`. Ours has a corner X whose click
is the same discard-and-hide (`HideUIPanel(OptionsFrame)`, and OnHide discards the pending
page), so that button answers to the reference's name.
**One behaviour changes because the alias went, and it is the right one.**
`FloatingChatFrame.lua` l.842/860 gate the chat tab and dock hover on
`not UIOptionsFrame:IsShown()`. That now reads the hidden stock frame, so chat-tab hover
works while OUR options window is open. The reference's gate exists because ITS interface
window is `frameStrata="FULLSCREEN"` with `setAllPoints` and covers the chat; ours is a
panel that does not. No adapter is added for it (2115).
BENILLA_ALLOW_OWN_UI=1 -->
<Script><![CDATA[
UIOptionsFrame = OptionsFrame
SoundOptionsFrame = OptionsFrame
OptionsFrameCancel = OptionsFrameCloseButton
]]></Script>
@@ -1,39 +0,0 @@
<Ui>
<!--
WHAT IS LEFT OF THIS FILE, AND WHY IT STILL EXISTS (decision 1841).
The four templates this file used to build — `OptionFrameBoxTemplate`,
`OptionsButtonTemplate`, `OptionsCheckButtonTemplate` and `OptionsSliderTemplate` — now come
off the player's chain out of `Interface\FrameXML\OptionsFrameTemplates.xml`, which the
manifest lists directly above this entry. Our transcription of them is gone.
**The same basename appears twice in benilla.toc on purpose.** A bare name is a file we
ship, a path is the reference's own (the manifest's rule since 1751), so the two entries are
unambiguous to the loader even though they read alike to a person. This one loads second and
adds exactly one name the chain's file does not have.
THAT NAME IS `UIOptionsCheckButtonTemplate`, and its home in the reference is
`UIOptionsFrame.xml` — a file we do not build at all, because benilla's own options window
is `OptionsFrame.xml`, a different design. So the template has no chain file to arrive from
and would simply vanish if this file did.
It is not ours and not invented: it is in `reference/1.12-globals.tsv`, and the corpus names
it at 8 `CreateFrame` call sites — the nameplate addons build their whole options panel out
of it. `panel_template_tests.rs` is the driver that says so.
WHEN THIS FILE GOES: the day `UIOptionsFrame.xml` migrates. The template comes from there,
this file has nothing left, and the manifest loses a line.
WHAT THE SWAP GAVE BACK. Our version factored the two widgets' hover into one
`BenillaOptionsWidget_ShowTooltip` helper, on the reasoning that "two copies of one
behaviour is how they end up disagreeing". The reference open-codes it inline in each
template's `<OnEnter>`, identically, and that is what runs now — the helper is gone with the
rest, and nothing else called it.
-->
<!-- ref UIOptionsFrame.xml l.6-10: a 26x26 override of the chain's own
`OptionsCheckButtonTemplate`, which it inherits from. -->
<CheckButton name="UIOptionsCheckButtonTemplate" inherits="OptionsCheckButtonTemplate" virtual="true">
<Size><AbsDimension x="26" y="26"/></Size>
</CheckButton>
</Ui>
+84 -32
View File
@@ -375,23 +375,92 @@ ScrollTemplates.xml
Interface\FrameXML\UIPanelTemplates.lua
Interface\FrameXML\UIPanelTemplates.xml
# ══ THE WINDOW TAB — the reference's own, off the player's chain (1751; decision 1993) ══
# `CharacterFrameTabButtonTemplate`, the big tab along a panel's bottom edge — the character,
# vendor and social windows' rows, and nothing else in the client (the small `TabButtonTemplate`
# is UIPanelTemplates.xml's, above).
# ══ THE OPTIONS WIDGET KIT — the reference's own, off the player's chain (1751, window 29) ══
# The four templates addons name for an options panel: `OptionFrameBoxTemplate`,
# `OptionsButtonTemplate`, `OptionsCheckButtonTemplate`, `OptionsSliderTemplate`. Self-contained —
# no `.lua`, no `<Include>` — and a descendant of UIPanelTemplates (`OptionsCheckButtonTemplate`
# inherits `UICheckButtonTemplate`, `OptionsButtonTemplate` inherits `UIPanelButtonTemplate`), so
# it stays seated after that file, which is the line above.
#
# THE REFERENCE NEVER GIVES THIS FILE A TOC LINE. Neither `FrameXML.toc` lists it (base or
# patch); `UIOptionsFrame.xml` pulls it in with `<Include file="CharacterFrameTemplates.xml"/>`
# above its own `<Script file=>` — "Need the character tabs here" — and that file is the
# reference's toc l.21. Our options window is ours, so the row is explicit; l.21 is exactly here,
# right after the UIPanelTemplates pair (l.16/17) and before StaticPopup (l.23).
# THE REFERENCE NEVER GIVES THIS FILE A TOC LINE EITHER: its **video** `OptionsFrame.xml` — toc
# l.19 — pulls it in with `<Include file="OptionsFrameTemplates.xml"/>` at that file's l.4. We do
# not load the video window (decision 2115 says why: its resolution dropdown's OnLoad calls
# `GetCurrentResolution`/`GetScreenResolutions`, nine unbuilt verbs), so the row is explicit and
# it sits in l.19's own seat — which is what puts it here rather than 200 lines below, where it
# used to sit before 2115.
#
# Its `<OnShow>` fits each tab to its text through `PanelTemplates_TabResize(0)` — which needs
# the line above it and a SYNCHRONOUS `GetStringWidth`, the reason our own transcription carried
# an OnUpdate settle instead until the engine gained one (1993 retires it, and 1002's clamp with
# it: a tab's cap is the reference's own number now, so a long label overflows the plate exactly
# as it does in the real client).
Interface\FrameXML\CharacterFrameTemplates.xml
# **THE SEAT IS A REQUIREMENT, not tidiness.** `UIOptionsFrame.xml` below declares
# `UIOptionsCheckButtonTemplate` as `inherits="OptionsCheckButtonTemplate"`, resolved at LOAD, and
# that name is this file's. l.19 before l.21 is exactly the reference's own answer to that.
#
# `framexml-transcription-diff.py` reports no handler our transcription was missing, and the swap
# GIVES BACK the reference's own inline hover: ours had factored it into a shared
# `BenillaOptionsWidget_ShowTooltip`, where the reference open-codes it once per widget.
Interface\FrameXML\OptionsFrameTemplates.xml
# ══ THE VIDEO WINDOW'S LUA, WITHOUT ITS WINDOW — the seven helpers (decision 2115) ══════════
# `Interface\FrameXML\OptionsFrame.lua` is the reference's toc l.19 file's own `<Script>`, and
# this row is that half of l.19 alone. **We deliberately do not load the video window's XML**
# (2115): its resolution dropdown's `OnLoad` calls `GetCurrentResolution()`/`GetScreenResolutions()`
# and nine video verbs are unbuilt, and eighteen of its checkboxes are 2004 hardware toggles.
#
# The `.lua` is a different thing: it is pure declarations and function definitions, and its last
# fifty lines (l.462-515) are SEVEN shared helpers `UIOptionsFrame.lua` calls and nothing else
# defines — `OptionsFrame_Enable/DisableCheckBox`, `.../Slider`, `.../DropDown` and
# `PlayClickSound`. `UIOptionsFrame_Load`, `_SetDefaults` and `_UpdateDependencies` call all seven,
# so the row below would load clean and die on pfUI's first `UIOptionsFrame_Load()` without this.
#
# WHAT ELSE IT PUBLISHES, and why each is fine:
# * `OptionsFrameCheckButtons` — the VIDEO window's option table (18 rows). A name 1.12 has and
# we did not; its `OptionsFrameCheckButtonN` frames do not exist here, exactly as they do not
# for any window we have not built.
# * `OptionsFrameSliders` — the video slider table, which **our own `OptionsFrame.xml` overwrites
# below** with the three rows our Graphics page actually drives (pfUI's `hdgraphic` module
# writes `OptionsFrameSliders[3].maxValue`). Order decides collisions and ours is later; that
# is the manifest's rule and this is the PaperDollFrame.lua shape (`ui_script::reference_ui`).
# * `OPTIONS_FARCLIP_MIN`/`_MAX`, `ANISOTROPIC_VALUES`, `OPTIONS_FRAME_WIDTH` — constants we do
# not declare.
# * `OptionsFrame_Init/_Load/_Save/_Cancel/_SetDefaults/_UpdateCheckboxes` and the video
# dropdowns' handlers — the video window's own, reaching frames that do not exist. Nothing in
# this manifest calls them; an addon that does gets a raise where it used to get "call a nil
# value", which is not worse and is 1203's "loudly absent" either way.
Interface\FrameXML\OptionsFrame.lua
# ══ THE STOCK INTERFACE OPTIONS WINDOW — loaded, and HIDDEN (decision 2115) ══════════════════
# **This row is not a migration, and it is the one row in this file where that has to be said.**
# benilla's own options window is `OptionsFrame.xml` below, and it stays the window the player
# opens — the director's call, twice (0985, and 1987's "we want to fully own that anyway"). This
# entry loads the reference's `UIOptionsFrame.xml` **alongside** it, and the stock file's own
# `hidden="true"` on its main frame is what keeps it off the screen. Nothing of ours may ever
# `Show()` it.
#
# WHY LOAD A WINDOW NOBODY OPENS: because third-party addons reach for it BY NAME. pfUI's
# `skins/blizzard/options-interface.lua` walks `UIOptionsFrame`, `UIOptionsFrameTab1/2`,
# `UIOptionsFrameCheckButton1..69`, `UIOptionsFrameSlider1..4`, the four dropdowns and the
# `BasicOptions*`/`AdvancedOptions*` boxes; its `modules/gui.lua` calls `UIOptionsFrame_Load()`
# and `UIOptionsFrame_Save()` around a GVAR write. Answering those names with an ALIAS onto our
# own window (which is what we did until 2115) is worse than answering nothing: the skin's
# `SetWidth(1024)` lands on the window the player actually opens. Real frames, hidden, is the
# honest answer — and it is free, because the file is the player's own.
#
# IT IS ALSO WHERE THE REFERENCE KEEPS ITS OPTIONS STATE, so loading it retires three duplications
# our own window carried (2115): `UIOptionsFrame_Init` assigns and `RegisterForSave`s all 38
# options uvars; `UIOptionsFrameCheckButtons` is declared here — the table stock
# `MultiActionBars.lua:10` writes five rows into at ITS load, which is why this row must stay
# above that one; and `UpdateNameplates` is this file's function.
#
# SEAT: the reference's own toc l.21, which is exactly here — after the UIPanelTemplates pair
# (l.16/17) and the widget kit (l.19, above), before StaticPopup (l.23). Load-bearing three ways:
# `UIOptionsCheckButtonTemplate` inherits the line above; three `GameMenuButtonTemplate` buttons
# and one `UIPanelCloseButton` come from UIPanelTemplates.xml; and `UIOptionsFrameCheckButtons`
# must exist before MultiActionBars.xml runs.
#
# IT BRINGS `CharacterFrameTemplates.xml` WITH IT, which is why that file has no row of its own
# any more. The reference gives it no toc line either: this file `<Include>`s it at l.4 ("Need
# the character tabs here") ahead of its own `<Script file=>`, so `CharacterFrameTabButtonTemplate`
# — the big tab along the character, vendor and social windows' bottom edge — lands at exactly the
# position our explicit row used to hold. Its `<OnShow>` fits each tab to its text through
# `PanelTemplates_TabResize(0)`, which needs UIPanelTemplates.lua above and a SYNCHRONOUS
# `GetStringWidth` (decision 1993 built that and retired our OnUpdate settle).
Interface\FrameXML\UIOptionsFrame.xml
# ══ THE DIALOG ENGINE — the reference's own, off the player's chain (1751; decision 1960) ══
# StaticPopup.xml + StaticPopup.lua: StaticPopupDialogs (all 76 of the reference's entries),
# StaticPopup_Show/Hide/Visible/Resize/OnUpdate, the four dialog instances, the edit boxes and the
@@ -535,23 +604,6 @@ Interface\FrameXML\FadingFrame.xml
# strings, which came from ours as `X = X or "…"` fallbacks and now come from the player's own
# GlobalStrings.lua like every other string in this interface.
Interface\FrameXML\ZoneText.xml
# ══ THE OPTIONS WIDGET KIT — the reference's own, off the player's chain (1751, window 29) ══
# The four templates addons name for an options panel: `OptionFrameBoxTemplate`,
# `OptionsButtonTemplate`, `OptionsCheckButtonTemplate`, `OptionsSliderTemplate`. Self-contained —
# no `.lua`, no `<Include>` — and a descendant of UIPanelTemplates (`OptionsCheckButtonTemplate`
# inherits `UICheckButtonTemplate`, `OptionsButtonTemplate` inherits `UIPanelButtonTemplate`), so
# it stays seated after that file.
#
# `framexml-transcription-diff.py` reports no handler our transcription was missing, and the swap
# GIVES BACK the reference's own inline hover: ours had factored it into a shared
# `BenillaOptionsWidget_ShowTooltip`, where the reference open-codes it once per widget.
Interface\FrameXML\OptionsFrameTemplates.xml
# …and ours, shrunk to the ONE template the chain's file does not carry:
# `UIOptionsCheckButtonTemplate`, whose reference home is `UIOptionsFrame.xml` — a file we do not
# build, because benilla's options window is `OptionsFrame.xml`, a different design. The corpus
# names it at 8 `CreateFrame` sites, so it cannot simply go. Same basename as the entry above by
# design: bare = ours, path = the reference's. Retires when UIOptionsFrame.xml migrates.
OptionsFrameTemplates.xml
# ══ THE COLOUR DIALOG — the reference's own, off the player's chain (1751, window 11) ══
# `ColorPickerFrame` (a `<ColorSelect>`), `OpacitySliderFrame`, `ColorPickerOkayButton`,
# `ColorPickerCancelButton`, and the separate `OpacityFrame` beside them. The file ships no `.lua`
+5 -5
View File
@@ -1038,7 +1038,7 @@ pub(super) fn seed_ui_fixture(
}
}
UiFixture::OptionsAudio => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The Audio page (0957): register the real CVar set first — the hermetic capture has
@@ -1052,7 +1052,7 @@ pub(super) fn seed_ui_fixture(
}
}
UiFixture::OptionsGraphics => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The Graphics page (0959), same posture as the Audio fixture: real CVar set, live
@@ -1065,7 +1065,7 @@ pub(super) fn seed_ui_fixture(
}
}
UiFixture::OptionsChat => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The Chat page (1589), the page fixtures' posture: the real CVar set, then the live
@@ -1097,7 +1097,7 @@ pub(super) fn seed_ui_fixture(
}
}
UiFixture::OptionsDropdownList => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The dropdown list open (0992, re-seated onto Camera Following Style by 1649), same
@@ -1134,7 +1134,7 @@ pub(super) fn seed_ui_fixture(
}
}
UiFixture::OptionsSearch => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// Mid-search (0984), same posture as the page fixtures: real CVar set, then the
+2 -2
View File
@@ -1530,7 +1530,7 @@ mod tests {
#[test]
fn only_entering_the_world_writes_the_cvar() {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut script = benilla_ui::script::UiScript::new().unwrap();
let script = benilla_ui::script::UiScript::new().unwrap();
script.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
let mut app = App::new();
@@ -1578,7 +1578,7 @@ mod tests {
fn a_replaced_vm_is_told_the_remembered_row_again() {
let (tx, _rx) = crossbeam_channel::unbounded();
let fresh = || {
let mut s = benilla_ui::script::UiScript::new().unwrap();
let s = benilla_ui::script::UiScript::new().unwrap();
s.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
s
};
+56
View File
@@ -403,6 +403,23 @@ impl Cooldowns {
}
}
/// **The list belongs to the world session, not to the process** (decision 2116) — the
/// session-end clear every other net-backed store already had
/// ([`crate::net::apply::session::disconnected`]).
///
/// `SMSG_INITIAL_SPELLS` carries the WHOLE set of cooldowns still running, at every world
/// entry, and [`Self::seed_initial`] appends (the reference's `AddCooldown 0x6e12c0` never
/// matches by id — see [`Self::add`]). So a store that survives the socket answers the *old*
/// session's records for the rest of their lives: a second login on the same character reads
/// its own stale copy rather than the wire's fresh remainder (measured live — a 600 s record
/// re-derived from the first login still winning over the second login's 594 s one), and a
/// login on a DIFFERENT character inherits cooldowns that are not theirs, for spells they may
/// not know. Nothing is lost by clearing: the next world entry — including 0065's seamless
/// reconnect, which is a full re-login — re-seeds from the wire.
pub(crate) fn clear_session(&mut self) {
self.wipe();
}
/// One `SMSG_SPELL_COOLDOWN` pair (`0x6e9460`'s per-entry law): a nonzero wire duration is
/// the spell recovery verbatim (category untracked); zero means "the spell's own Spell.dbc
/// recovery + category recovery". `SPELL_ATTR_COOLDOWN_ON_EVENT` parks it and suppresses the
@@ -828,6 +845,45 @@ mod tests {
assert_eq!((ch.remaining_ms, ch.duration_ms), (10_000, 15_000));
}
/// **The session end empties the list, so the next login's wire is the whole truth**
/// (decision 2116). `seed_initial` appends by design (the reference's `AddCooldown` never
/// matches by id), so without the clear the previous session's record stays and — being
/// stamped with the FULL remainder it had at the earlier login — outlives and outbids the
/// fresh one. Measured live before the fix: a second world entry read `d=600` where the wire
/// had just said 594.
#[test]
fn a_session_end_empties_the_list_so_the_next_logins_wire_is_the_whole_truth() {
use benilla_protocol::messages::SpellCooldown;
let wire = |ms| SpellCooldown {
spell_id: 12975, // Last Stand — 10 minutes, no category
item_id: 0,
category: 0,
spell_cd_ms: ms,
category_cd_ms: 0,
};
let t0 = Instant::now();
let mut cds = Cooldowns::default();
// Login one: 600 s still to run.
cds.seed_initial(&wire(600_000), t0);
assert_eq!(cds.info(12975, 0, None, t0).duration_ms, 600_000);
// …the player logs out six seconds later and comes straight back in; the server's list
// now says 594 s.
let t1 = t0 + Duration::from_secs(6);
cds.clear_session();
assert!(cds.records.is_empty(), "the list dies with the session");
cds.seed_initial(&wire(594_000), t1);
let read = cds.info(12975, 0, None, t1);
assert_eq!(
(cds.records.len(), read.duration_ms, read.remaining_ms),
(1, 594_000, 594_000),
"one record, the second login's — a surviving first-login record would answer 600 s \
and go on answering it for the whole cooldown"
);
}
#[test]
fn item_use_cooldowns_key_on_the_item_and_respect_the_wire_triple() {
let t0 = Instant::now();
+21 -10
View File
@@ -444,9 +444,10 @@ pub(crate) const REGISTERED: &[Registered] = &[
// **`same`, not `ours`, and that distinction is the point**: the reference has no CVar to
// match, but it very much has a *setting* to match, and it boots both halves OFF —
// `UIOptionsFrame_Init` assigns `NAMEPLATES_ON = nil` / `FRIENDNAMEPLATES_ON = nil` and
// `OptionsFrame_ApplySavedSettings` only calls `ShowNameplates()` on a truthy SAVED value
// (the install's `Interface\FrameXML\UIOptionsFrame.lua` l.180-183 / l.769-775). A fresh
// 1.12 client draws no plates until V is pressed. Enemy plates shipped ON here from 0167
// `UpdateNameplates` only calls `ShowNameplates()` on a truthy value (the install's
// `Interface\FrameXML\UIOptionsFrame.lua` l.180-183 / l.769-775 — both of them the stock
// file's own, off the chain since 2115; our copies of each are gone). A fresh 1.12 client
// draws no plates until V is pressed. Enemy plates shipped ON here from 0167
// until 1804 — `VPlateMode::default()` carries that history.
same(crate::vplates::CVAR_ENEMIES, "0"),
same(crate::vplates::CVAR_FRIENDS, "0"),
@@ -827,6 +828,22 @@ pub(crate) struct CvarPersist {
}
impl CvarPersist {
/// The saved-base pairs a VM's table is seeded from — the file's entries minus the ones an
/// env var owns this session, which are never persisted.
///
/// Extracted so `ui_script::lifecycle`'s world-entry edge can run the same seed before the
/// interface loads (decision 2115): the reference's own `UIOptionsFrame.xml` reads two CVars
/// in its dropdowns' `OnLoad`, and a `/reloadui` builds a fresh VM and loads the whole
/// interface before [`sync_cvars`]'s `Update` claim gets a turn. The ORDER at both call sites
/// is this first, `register_cvars` second (1291) — reversed, a reload resets every knobless
/// CVar to its factory value.
pub(crate) fn saved_base(&self) -> impl Iterator<Item = (String, String)> + '_ {
self.file
.iter()
.filter(|(k, _)| !self.env_overridden.contains(&k.to_ascii_lowercase()))
.map(|(k, v)| (k.clone(), v.clone()))
}
/// One CVar as `config.toml` holds it — matched case-insensitively, so a hand-edited
/// spelling still answers.
///
@@ -1437,13 +1454,7 @@ fn sync_cvars(
// knobless CVar (`statusBarText`) and an addon-declared one across a VM replacement; the
// knob-derived session rows below still win for every key a host knob backs, and an
// env-overridden key keeps its env value the same way (its knob carries it).
script.set_cvar_saved_base(
persist
.file
.iter()
.filter(|(k, _)| !persist.env_overridden.contains(&k.to_ascii_lowercase()))
.map(|(k, v)| (k.clone(), v.clone())),
);
script.set_cvar_saved_base(persist.saved_base().collect::<Vec<_>>());
script.register_cvars(registered_pairs());
// The Video dropdown's menu — what this device actually accepts, enumerated once at
// `finish()` by `view::MsaaSupportPlugin` (decision 1631) and handed over whole. Pushed
+1
View File
@@ -663,6 +663,7 @@ pub(crate) fn apply_net_updates(
&mut social,
&mut guild,
&mut gm_ticket,
&mut ui_actions.9,
&mut aura.6,
&mut disconnects,
);
+114 -29
View File
@@ -16,9 +16,10 @@
use bevy::prelude::*;
use benilla_protocol::messages::{
AttackerState, DamageShield, DispelFailed, EnchantmentLog, EnvironmentalDamageLog,
PartyKillLog, PeriodicAuraLog, PeriodicTick, SpellDamageLog, SpellDispelLog, SpellEnergizeLog,
SpellHealLog, SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog,
power_display_scale, AttackerState, DamageShield, DispelFailed, EnchantmentLog,
EnvironmentalDamageLog, PartyKillLog, PeriodicAuraLog, PeriodicTick, SpellDamageLog,
SpellDispelLog, SpellEnergizeLog, SpellHealLog, SpellInstaKillLog, SpellLogExecute,
SpellLogMiss, SpellOutcomeLog,
};
use crate::ui_chat::combat::{self, Family, Fills, UnitClass};
@@ -341,6 +342,11 @@ pub(super) fn spell_heal_log(
}
/// `SMSG_SPELLENERGIZELOG` → a power-gain line, also a BUFF type.
///
/// **The wire amount is RAW and the sentence wants the displayed figure** (decision 2117): the
/// reference's own handler `0x5e8a90` divides by `0x6e7130(powerType)` at `0x5e8af3` before it
/// hands the number to anything, so a warrior's one point of Unbridled Wrath rage — 10 on the
/// wire — words as *"You gain 1 Rage from …"*, not 10.
pub(super) fn spell_energize_log(
s: SpellEnergizeLog,
ctx: &ChatCtx,
@@ -359,7 +365,7 @@ pub(super) fn spell_energize_log(
let fills = Fills {
spell,
power: Some(s.power),
amount: i64::from(s.amount),
amount: power_gain(s.power, s.amount),
..Default::default()
};
queue(
@@ -413,35 +419,48 @@ pub(super) fn periodic_aura_log(
..Default::default()
},
),
// The same divide as the direct packet, from the same law and the same table — the
// reference's periodic handler `0x626dd0` applies it at `0x627087` before wording.
PeriodicTick::Energize { power, amount } => (
combat::POWERGAIN,
true,
Fills {
spell: spell.clone(),
power: Some(power),
amount: i64::from(amount),
amount: power_gain(power, amount),
..Default::default()
},
),
// A mana leech is one sentence about two transfers: what the victim lost and what the
// caster gained. vmangos sends the drained amount and a multiplier, not the gain, so
// the gained figure is `amount * multiplier` — the same product the server applies.
//
// **Both figures are wire-raw** and the shared leech/drain formatter `0x627930` states
// the arithmetic exactly (decision 2117): `drained = amount / div`, `gained =
// trunc(amount·multiplier) / div` — the product truncates first, then divides — and a
// `drained` of zero drops the line, which is the reference's own test and the reason
// the periodic arm needs it as much as the execute-log one does.
PeriodicTick::ManaLeech {
power,
amount,
multiplier,
} => (
combat::SPELLPOWERLEECH,
true,
Fills {
spell: spell.clone(),
power: Some(power),
amount: i64::from(amount),
amount2: (f64::from(amount) * f64::from(multiplier)) as i64,
power2: Some(power),
..Default::default()
},
),
} => {
let Some((drained, gained)) = leech_figures(power, amount, multiplier) else {
continue;
};
(
combat::SPELLPOWERLEECH,
true,
Fills {
spell: spell.clone(),
power: Some(power),
amount: drained,
amount2: gained,
power2: Some(power),
..Default::default()
},
)
}
};
let Some(kind) = combat::periodic_kind(caster, buff) else {
continue;
@@ -791,11 +810,9 @@ pub(super) fn spell_log_execute(
multiplier,
} => {
let victim = ctx.classify(target, stores);
let div = power_divisor(power);
let drained = i64::from(amount) / div;
if drained == 0 {
let Some((drained, gained)) = leech_figures(power, amount, multiplier) else {
continue;
}
};
if power == POWER_HAPPINESS {
// `0x627de0`: the subject is the pet's OWNER and the named thing is the
// pet, at the literal misc-info type.
@@ -825,7 +842,6 @@ pub(super) fn spell_log_execute(
// `|multiplier| >= 2^-22` is the reference's own epsilon (`[0x8029d4]`), not a
// round number of ours.
let leech = multiplier.abs() >= LEECH_EPSILON;
let gained = ((f64::from(amount) * f64::from(multiplier)) as i64) / div;
queue(
log,
ctx,
@@ -1001,14 +1017,41 @@ const EFFECT_DISMISS_PET: u32 = 102;
/// `|multiplier| >= 2^-22` — the reference's own leech/drain discriminator (`[0x8029d4]`).
const LEECH_EPSILON: f32 = 1.0 / 4_194_304.0;
/// `0x6e7130(powerType)` — the divisor a power's log amounts are reported in
/// (`[powerType*4 + 0x86f978]` = `{1, 10, 1, 1, 1000}`). Rage is stored ×10 and happiness ×1000.
/// `0x6e7130(powerType)` as the log formatters take it — [`power_display_scale`]'s table, widened
/// for the `i64` arithmetic every power line does.
///
/// It delegates rather than re-tabulating: this file used to carry its own copy of `{1, 10, 1, 1,
/// 1000}`, and the copy was applied at exactly one of the four sites that need it (decision 2117).
fn power_divisor(power: u32) -> i64 {
match power {
1 => 10,
POWER_HAPPINESS => 1000,
_ => 1,
}
i64::from(power_display_scale(power))
}
/// The figure a `POWERGAIN` line words: the wire amount on the display scale.
///
/// The reference does this **in the packet handler**, once per packet — `0x5e8af3` for
/// `SMSG_SPELLENERGIZELOG`, `0x627087` for an energize tick — so the chat line and the
/// `COMBAT_TEXT_UPDATE` push can never disagree. We have two consumers instead of one call, so the
/// law is a named function rather than a local (decision 2117).
fn power_gain(power: u32, amount: u32) -> i64 {
i64::from(amount) / power_divisor(power)
}
/// The `(drained, gained)` pair a leech/drain sentence words, or `None` when the line is dropped.
///
/// One function because the reference has one formatter: `0x627930` serves both
/// `SMSG_SPELLLOGEXECUTE` effect 8 `POWER_DRAIN` and `SMSG_PERIODICAURALOG` aura 64, and the three
/// rules are its own — `drained = amount / div`, `gained = trunc(amount·multiplier) / div` (the
/// product truncates *before* the divide, which is why this is arithmetic and not two divides),
/// and **`drained == 0` drops the line**.
fn leech_figures(power: u32, amount: u32, multiplier: f32) -> Option<(i64, i64)> {
let div = power_divisor(power);
let drained = i64::from(amount) / div;
(drained != 0).then(|| {
(
drained,
((f64::from(amount) * f64::from(multiplier)) as i64) / div,
)
})
}
/// A unit's owner guid — `CHARMEDBY` first, then `CREATEDBY`, the same pair
@@ -1182,3 +1225,45 @@ fn queue_named(
log.push_combat(line);
}
}
#[cfg(test)]
mod tests {
use super::{leech_figures, power_gain};
/// The report this arithmetic was written for: MSBT read `+10 Rage` off our combat log where
/// the reference reads `+1 Rage`, for the same swing against the same server.
///
/// The wire number really is ten. `Unbridled Wrath Effect` (12964) is
/// `effect1 = 30 SPELL_EFFECT_ENERGIZE`, `effectMiscValue1 = 1 POWER_RAGE`,
/// `effectBasePoints1 = 9` — base points are *n − 1*, so vmangos energizes by **10** and
/// `SendEnergizeSpellLog` ships that same 10 (`SpellCaster.cpp:796-813`, it logs exactly what
/// it hands `ModifyPower`, and the stored rage field is the ×10 one). Rage is the only power a
/// player can gain where raw and displayed differ, which is why it is the one that was seen.
#[test]
fn a_rage_gain_words_the_displayed_figure_not_the_wire_one() {
assert_eq!(power_gain(1, 10), 1, "Unbridled Wrath: one point of rage");
assert_eq!(power_gain(1, 100), 10, "Bloodrage: ten");
// Every other power a POWERGAIN line can name is one-to-one, so the divide has to be a
// table lookup and not an `if rage` — mana, focus and energy must come through untouched.
assert_eq!(power_gain(0, 300), 300);
assert_eq!(power_gain(2, 20), 20);
assert_eq!(power_gain(3, 20), 20);
}
/// `0x627930`'s three rules, one function for both of its packets.
#[test]
fn a_leech_divides_both_figures_and_drops_a_zero_drained_line() {
// Mana leeches one-to-one: 120 drained, half of it gained.
assert_eq!(leech_figures(0, 120, 0.5), Some((120, 60)));
// The truncation ORDER is observable, and only on a scaled power: the reference truncates
// the product and divides after (`trunc(25 · 0.9) = 22`, `22 / 10 = 2`). Dividing first
// would give `(25 / 10) · 0.9 = 1`, and nothing downstream would notice the off-by-one.
assert_eq!(leech_figures(1, 25, 0.9), Some((2, 2)));
// Below one displayed point there is no line at all — the reference's own `drained == 0`
// test, which only a scaled power can reach with a nonzero wire amount.
assert_eq!(leech_figures(1, 9, 1.0), None);
assert_eq!(leech_figures(0, 0, 1.0), None);
// Happiness is the ×1000 arm; a pet's 1500 raw is one displayed point.
assert_eq!(leech_figures(4, 1500, 0.0), Some((1, 0)));
}
}
+12 -4
View File
@@ -17,8 +17,8 @@
use bevy::prelude::*;
use benilla_protocol::messages::{
DamageShield, ExplorationXp, LevelUpInfo, PeriodicAuraLog, PeriodicTick, SpellDamageLog,
SpellEnergizeLog, SpellHealLog, SpellLogMiss, XpGain,
power_display_scale, DamageShield, ExplorationXp, LevelUpInfo, PeriodicAuraLog, PeriodicTick,
SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, XpGain,
};
use crate::combat_text::{damage_color, miss_word, spell_text, CombatTextSpawn, DamageSource};
@@ -301,10 +301,14 @@ pub(super) fn periodic_aura_log(
),
extra: Some(amount.to_string()),
}),
// The displayed figure, not the wire's — `0x626dd0` divides by
// `0x6e7130(powerType)` at `0x627087` **before** it pushes the COMBAT_TEXT tag
// (`0x494770`) and before it words the chat line, so both consumers see the same
// number (decision 2117).
PeriodicTick::Energize { power, amount } => {
power_message_type(power).map(|message_type| CombatTextEvent {
message_type,
data: Some(amount.to_string()),
data: Some((amount / power_display_scale(power)).to_string()),
extra: None,
})
}
@@ -462,6 +466,10 @@ pub(super) fn spell_heal_log(
/// NO `UNIT_COMBAT` fires here: the 5875 engine has no ENERGIZE emission — the string is absent
/// from the whole binary (§5-verified, wow-re `unit-combat-event-law.md`; the shipped
/// CombatFeedback.lua ENERGIZE arm is dead code in 1.12).
///
/// The amount is the **displayed** figure: the handler `0x5e8a90` divides by
/// `0x6e7130(powerType)` at `0x5e8af3` and hands that one number to the COMBAT_TEXT push
/// (`0x494770`) and to the chat formatter (`0x62ca00`) alike (decision 2117).
pub(super) fn spell_energize_log(
s: SpellEnergizeLog,
self_guid: &SelfGuid,
@@ -471,7 +479,7 @@ pub(super) fn spell_energize_log(
if let Some(message_type) = power_message_type(s.power) {
center.write(CombatTextEvent {
message_type,
data: Some(s.amount.to_string()),
data: Some((s.amount / power_display_scale(s.power)).to_string()),
extra: None,
});
}
@@ -190,6 +190,7 @@ pub(super) fn disconnected(
social: &mut crate::ui_social::SocialState,
guild: &mut crate::ui_guild::GuildState,
gm_ticket: &mut crate::ui_gm_ticket::GmTicketState,
cooldowns: &mut crate::cooldowns::Cooldowns,
pending_transfer: &mut PendingTransfer,
disconnects: &mut MessageWriter<DisconnectedMessage>,
) {
@@ -282,6 +283,13 @@ pub(super) fn disconnected(
// counters go with it, so the first `SMSG_GMTICKET_GETTICKET` of the new session re-fires
// `UPDATE_TICKET` rather than being diffed away against the old character's answer count.
gm_ticket.clear_session();
// The cooldown list is session-scoped for the same reason and had been missing from this
// sweep since it was built (decision 2116). `SMSG_INITIAL_SPELLS` carries every cooldown
// still running at every world entry and `seed_initial` APPENDS, so a list that outlives the
// socket answers the old session's records: a second login on the same character reads its
// own stale copy over the wire's fresh remainder, and a login on a different character
// inherits cooldowns that were never theirs.
cooldowns.clear_session();
// The death stores are session-scoped too: a reclaim expiry, resurrect offer, or corpse
// marker must not survive the socket (the reconnect re-sends the reclaim delay when dead).
*death_net = crate::death::DeathNet::default();
+4
View File
@@ -59,6 +59,10 @@ pub(crate) use edit::ChannelState;
pub(crate) use event::event_name;
pub(crate) use event::{default_color, ChatEvent, ChatEventKind};
pub(crate) use feed::ChatLog;
/// The per-character chat cache's restore. Called from the world-entry UI load rather than from a
/// system, because the two events it fires have to precede the session's first chat line and
/// `PLAYER_LOGIN` — see [`settings::restore_chat_looks`] and decision 2119.
pub(crate) use settings::restore_chat_looks;
pub(crate) struct UiChatPlugin;
@@ -339,6 +339,7 @@ mod tests {
ChannelState {
joined: joined.iter().map(|n| Some((*n).to_string())).collect(),
channels: catalog(),
..Default::default()
}
}
+50
View File
@@ -136,9 +136,59 @@ pub(crate) struct ChannelState {
/// needs no extra bookkeeping at join time. Empty without an install, which degrades to
/// "no zone channels, arg7 always 0" rather than to an error.
pub channels: benilla_formats::ChatChannelsCatalog,
/// **The `ZONECHANNELS` mask** — the reference's `DWORD ds:0xb6e5e0`, bit `1 << (ChannelID-1)`
/// (decision 2120; wow-re `system/ui/scratch/zone-chat-channel-autojoin.md` §3 carries the
/// complete 8-site census of that global).
///
/// **It is durable state, not a view of [`Self::joined`].** The reference seeds it once — from
/// the chat cache's header line (`0x498d83`, an overwrite) or, with no usable file, from every
/// `ChatChannels.dbc` row carrying `INITIAL` (`0x4997fc`) — then ORs a bit on each
/// server-confirmed join (`0x49bbaf`, the `YOU_JOINED` arm) and clears one only on an explicit
/// leave-by-name (`0x49f10a`/`0x49f11a` inside `0x49ee70`). The zone walk's own LEAVE, sent
/// every time you cross a border or walk out of a capital, does **not** touch it — which is
/// why `Trade`'s bit survives a logout in Elwynn Forest.
///
/// Deriving it from the live roster at write time instead is what decision 2120 corrects, and
/// it was not cosmetic: the per-window line is written as `the window's own bits AND this
/// mask`, so one save taken while the roster was momentarily empty — the session-end flush
/// racing `end_session_channels` on the same unordered `OnExit(InWorld)` edge — wrote
/// `ZONECHANNELS 0` into every block, and the next login rebuilt window 1 with **no channels
/// at all**. The stock `ChatFrame_OnEvent` drops every `CHANNEL*` line whose channel the
/// window does not carry (ref `ChatFrame.lua` l.1374-1391, `if found == 0 … return`), so that
/// character silently lost its `Joined Channel:` notices *and* all General/Trade speech, for
/// good. Ten of the twenty files in this repo's own config folder had reached that state,
/// including the director's own character.
///
/// Not cleared by the session end: it belongs to the character's file, and the login that
/// reads that file is what seats it.
pub zone_mask: u32,
}
/// The bit `id` occupies in a `ZONECHANNELS` word — `1 << (ChannelID - 1)`; nothing outside
/// `1..=32` has one.
pub(crate) fn zone_bit(id: u32) -> u32 {
if id == 0 || id > 32 {
0
} else {
1 << (id - 1)
}
}
impl ChannelState {
/// A server-confirmed join sets the channel's `ZONECHANNELS` bit — the reference's `0x49bbaf`,
/// which ORs `1 << (slot.ChannelID - 1)` in the `YOU_JOINED` arm. A custom channel has no DBC
/// id and so no bit, which is why this is a no-op for one.
pub(crate) fn note_zone_channel_joined(&mut self, name: &str) {
self.zone_mask |= zone_bit(self.channels.zone_channel_id(name));
}
/// An **explicit** leave clears the bit — `0x49f10a`/`0x49f11a` inside leave-by-name
/// `0x49ee70`, and only there. The zone walk's LEAVE goes out on a different path and leaves
/// the mask alone: crossing a border is not "I left this channel".
pub(crate) fn note_zone_channel_left(&mut self, name: &str) {
self.zone_mask &= !zone_bit(self.channels.zone_channel_id(name));
}
/// The 1-based number of `name` (case-insensitive), if joined.
pub(crate) fn number_of(&self, name: &str) -> Option<u32> {
self.joined
+7
View File
@@ -683,6 +683,13 @@ pub(super) fn deliver(
let notice = event
.notice_byte()
.filter(|_| event.kind == Some(ChatEventKind::ChannelNotice));
if notice == Some(channel_notice::YOU_JOINED) {
// The reference's `0x49bbaf`: the confirmed join is what sets the channel's
// `ZONECHANNELS` bit, and it is the ONLY thing that grows that mask at runtime
// (decision 2120). Outside the slot claim below because it is not about slots — a
// re-confirmation of a channel we already number still owns the bit.
channels.note_zone_channel_joined(&event.channel);
}
if notice == Some(channel_notice::YOU_JOINED) && channels.number_of(&event.channel).is_none() {
match channels.claim_slot(&event.channel) {
Some(slot) => {
+13 -2
View File
@@ -187,7 +187,10 @@ fn engine_verbs(
pub(super) fn drain_chat_input(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut chat_log: ResMut<super::feed::ChatLog>,
channels: Res<super::edit::ChannelState>,
// Mutable for one reason: an EXPLICIT leave clears the channel's `ZONECHANNELS` bit
// (decision 2120, the reference's `0x49f10a` inside leave-by-name `0x49ee70`). The zone
// walk's own LEAVE, one module over, deliberately does not.
mut channels: ResMut<super::edit::ChannelState>,
commands: Res<NetCommands>,
emotes: Option<Res<crate::sound::EmoteSounds>>,
selection: Res<Selection>,
@@ -264,6 +267,9 @@ pub(super) fn drain_chat_input(
.send(ClientCommand::JoinChannel { name, password });
}
ParsedChat::Leave { name } => {
// `/leave` is leave-by-name: the reference clears the mask bit here and nowhere
// else (decision 2120).
channels.note_zone_channel_left(&name);
let _ = commands.0.send(ClientCommand::LeaveChannel { name });
}
ParsedChat::ChatList { name } => {
@@ -970,7 +976,12 @@ pub(super) fn drain_chat_input(
use benilla_ui::script::ChannelCommand as C;
let cmd = match cmd {
C::Join { name, password } => ClientCommand::JoinChannel { name, password },
C::Leave { name } => ClientCommand::LeaveChannel { name },
C::Leave { name } => {
// `LeaveChannelByName` — the same leave-by-name path as `/leave`, so the
// same mask clear (decision 2120).
channels.note_zone_channel_left(&name);
ClientCommand::LeaveChannel { name }
}
C::List { name } => ClientCommand::ChannelList { name },
// `ListChannels()` — the joined roster, numbered the way `/N` addresses it.
C::ListAll => {
+301 -65
View File
@@ -99,6 +99,7 @@ use bevy::prelude::*;
use benilla_ui::script::{ChatTypeColor, ChatWindowLook, UiScript, MESSAGE_GROUPS};
use super::edit::zone_bit;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_script::VmMemo;
@@ -117,6 +118,26 @@ const HEADER: &str = "\
# Written whole; the tab menu, /join and ChangeChatColor are what move it.
";
/// **The writer generation, carried in the header comment** (decision 2120) — the one thing in
/// this file that is ours and not the reference's grammar, and it is a repair marker, not a
/// format version.
///
/// Every file this client wrote before 2120 composed its `ZONECHANNELS` words from the LIVE
/// channel roster. A save taken while that roster was momentarily empty — the session-end flush
/// racing `end_session_channels` on the same unordered `OnExit(InWorld)` edge — wrote
/// `ZONECHANNELS 0` in the header *and*, through `window bits AND header mask`, in every window
/// block. The next login then rebuilt window 1 with no channels, and the stock
/// `ChatFrame_OnEvent` silently drops every `CHANNEL*` line a window does not carry — so the
/// character lost its `Joined Channel:` notices and all General/Trade speech, permanently, because
/// nothing but the loader's no-file path ever seeds a window's channel list.
///
/// A file without this line is one of those. [`restore_chat_looks`] re-seeds window 1 from
/// `ChatChannels.dbc`'s `INITIAL` rows exactly as the no-file path does, and the next save stamps
/// the marker, so the repair fires once per character and then never again. Bump it only for
/// another repair of our own making; it is a comment line, so a reference client reading this file
/// skips it like the rest of the header.
const WRITER_GENERATION: &str = "# benilla-writer 2 (decision 2120)";
/// Which character's file we are on, where it lives, and whether it is owed a write.
#[derive(Resource, Default)]
pub(super) struct ChatWindowFile {
@@ -136,31 +157,71 @@ pub(super) struct ChatWindowFile {
/// What a file parses to: the windows it names (0-based index, the record), the `COLORS` rows
/// it carries in file order, and the custom channels its header lists for re-joining.
#[derive(Debug, Default, PartialEq, Eq)]
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Parsed {
pub(super) looks: Vec<(usize, ChatWindowLook)>,
pub(super) colors: Vec<(String, [u8; 3])>,
pub(super) joined: Vec<String>,
/// The header's `ZONECHANNELS` word — the reference's `ds:0xb6e5e0`, loaded from the file as
/// an overwrite (`0x498d83`). `None` when the file carried no such line, which is what makes
/// the loader fall back to the DBC seed rather than to a mask of 0 (decision 2120).
pub(super) zone_mask: Option<u32>,
/// `OPTION_GUILD_RECRUITMENT_CHANNEL` — the auto-join latch `GetGuildRecruitmentMode` returns
/// (decision 2115). `STANDARD` is 0 and **anything else, a missing word included, is 1**: that
/// is the reference's own reading (wow-re `chat-cache-grammar.md` — `"STANDARD"` takes the
/// `0x49ea70(ecx=0)` leg and every other word takes `ecx=1`), and it is why a file with no such
/// line at all reads as AUTO here, exactly as it does there.
pub(super) guild_recruitment_auto: bool,
}
/// The bit a zone channel's id occupies in a `ZONECHANNELS` word.
fn zone_bit(id: u32) -> u32 {
if id == 0 || id > 32 {
0
} else {
1 << (id - 1)
impl Default for Parsed {
fn default() -> Self {
Self {
looks: Vec::new(),
colors: Vec::new(),
joined: Vec::new(),
zone_mask: None,
// The boot value, and it is evidence rather than a guess: all 33 `chat-cache.txt`
// files the reference client itself wrote in this repo's install say `AUTO`, on
// characters that never opened the option.
guild_recruitment_auto: true,
}
}
}
/// Render the file exactly as the writer does (§1), window order. `joined` is the client's
/// current channel roster — the custom names go to the header's `CHANNELS`, the zone ids to the
/// two `ZONECHANNELS` words.
fn render(looks: &[ChatWindowLook], colors: &[ChatTypeColor], joined: &[(String, u32)]) -> String {
let zone_mask = joined.iter().fold(0, |m, (_, id)| m | zone_bit(*id));
/// Render the file exactly as the writer does (§1), window order.
///
/// `joined` is the client's current channel roster and supplies the header's `CHANNELS` names —
/// **the custom ones only** (`id == 0`): a zone channel is never written as a name, it travels as
/// its bit (wow-re `chat-cache-grammar.md` §1.1, which is why all 34 stock files have an empty
/// per-window `CHANNELS` block beside a non-zero `ZONECHANNELS`).
///
/// `zone_mask` is [`super::edit::ChannelState::zone_mask`], written raw into the header
/// (`0x499c19`) and ANDed with each window's own bits for its block (`0x49a133`/`0x49a138`). It is
/// **passed in rather than derived from `joined`** — decision 2120, and the whole bug: derived, it
/// was 0 on any save taken while the roster was empty, and `window AND 0` erased the window's
/// channel list for good.
fn render(
looks: &[ChatWindowLook],
colors: &[ChatTypeColor],
joined: &[(String, u32)],
zone_mask: u32,
guild_recruitment_auto: bool,
) -> String {
let mut out = String::from(HEADER);
out.push_str(
"\nVERSION 2\n\nADDEDVERSION 2\n\nOPTION_GUILD_RECRUITMENT_CHANNEL AUTO\n\nCHANNELS\n",
);
// The repair marker (decision 2120) — a comment line, so the reference's own reader skips it.
out.push_str(WRITER_GENERATION);
out.push('\n');
// The latch is the live one now, not a literal (decision 2115): `SetGuildRecruitmentMode`
// writes it and this is the one place it persists.
let recruitment = if guild_recruitment_auto {
"AUTO"
} else {
"STANDARD"
};
out.push_str(&format!(
"\nVERSION 2\n\nADDEDVERSION 2\n\nOPTION_GUILD_RECRUITMENT_CHANNEL {recruitment}\n\nCHANNELS\n"
));
for (name, id) in joined {
if *id == 0 {
out.push_str(name);
@@ -308,9 +369,19 @@ fn parse(text: &str, rows: &[(u32, String)]) -> Parsed {
block = Block::Joined;
} else if head.eq_ignore_ascii_case("ADDEDVERSION") {
added_version = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
} else if head.eq_ignore_ascii_case("ZONECHANNELS") {
// The reference's top-level arm is an OVERWRITE of `ds:0xb6e5e0` (`0x498d83`), not
// an OR — the file is the whole truth about which zone channels this character
// holds (decision 2120).
out.zone_mask = it.next().and_then(|v| v.trim().parse::<u32>().ok());
} else if head.eq_ignore_ascii_case("OPTION_GUILD_RECRUITMENT_CHANNEL") {
// The reference's own test, whole and case-folded: `STANDARD` is the only word
// that means 0; every other word — and no word at all — means 1 (decision 2115).
out.guild_recruitment_auto = !it
.next()
.is_some_and(|w| w.eq_ignore_ascii_case("STANDARD"));
}
// VERSION, OPTION_*, and the header's ZONECHANNELS (the joined mask — ours is
// whatever the zone walk joins) are the reference's and not read here.
// VERSION is the reference's and not read here.
continue;
};
if is_end {
@@ -420,23 +491,59 @@ fn roster(channels: &super::edit::ChannelState) -> Vec<(String, u32)> {
/// Restore the file into a fresh VM — once per character per VM — and fire the loader's two
/// events, file or no file.
fn load_chat_looks(
script: Option<NonSendMut<UiScript>>,
roster_res: Res<crate::char_select::Roster>,
channels: Res<super::edit::ChannelState>,
commands: Res<NetCommands>,
mut file: ResMut<ChatWindowFile>,
) {
let Some(mut script) = script else { return };
let Some(id) = crate::ui_macro::identity(&roster_res) else {
///
/// **This runs INSIDE the world-entry UI load, not from `Update`** (decision 2119), and the two
/// events are why. It is the only caller of `UPDATE_CHAT_WINDOWS`, and that event is the only
/// thing that registers a chat frame for any `CHAT_MSG_*` — `ChatFrame_OnEvent`'s arm (ref
/// `ChatFrame.lua` l.1261-1273) is what calls
/// `ChatFrame_RegisterForMessages(GetChatWindowMessages(this:GetID()))`. Anything fired before it
/// reaches no window and is gone with no trace (1784: a rejected `AddMessage` is a silent skip).
/// As an `Update` system this landed on the same frame the parked VM came back and the whole
/// queued login burst drained, with no ordering between them — so the server's MOTD, sent as
/// `CHAT_MSG_SYSTEM` right after `SMSG_LOGIN_VERIFY_WORLD`, was routed at a window registered for
/// nothing. Measured on a live login: `net: server says — Welcome to World of Warcraft!` at
/// `…45.155929`, this restore at `…45.157625`.
///
/// It is also the only caller of the `UPDATE_CHAT_COLOR` burst, and that one has to precede
/// `PLAYER_LOGIN`. The burst's `WHISPER` row mirrors itself into `ChatTypeInfo["REPLY"]`
/// (`ChatFrame.lua` l.1357-1365) — an entry the engine's 94-row registry does not carry, so its
/// `.id` is **0** (`ui_script::chat_tests` asserts exactly that) — and `UpdateColorByID(0, …)`
/// repaints every line already in the window whose id is 0, which is every line printed with no
/// explicit colour. `AceConsole-2.0`'s `Print` is exactly that call
/// (`AddMessage(text, nil, nil, nil, nil, 5)`), so running the burst after the addons had printed
/// repainted their login lines whisper-pink: measured `(255,128,255)` on Bartender2's login line
/// against the reference's `(255,255,255)`, in a run where the same `AddMessage` a second later
/// came out white.
pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) {
let Some(id) = world
.get_resource::<crate::char_select::Roster>()
.and_then(crate::ui_macro::identity)
else {
return;
};
if file.identity.get(&script).as_ref() == Some(&id) {
// The `ChannelState` reads are taken as owned rows up front: the restore needs the DBC
// shortcut table and the auto-join rows while it also holds `ChatWindowFile` mutably, and a
// `&mut World` hands out one resource borrow at a time.
let Some(channels) = world.get_resource::<super::edit::ChannelState>() else {
return;
};
let rows = shortcut_rows(channels);
let auto_rows: Vec<(String, u32)> = channels
.channels
.auto_join_rows()
.map(|r| (r.shortcut.clone(), r.id))
.collect();
let commands = world.get_resource::<NetCommands>().map(|c| c.0.clone());
let Some(mut file) = world.get_resource_mut::<ChatWindowFile>() else {
return;
};
if file.identity.get(script).as_ref() == Some(&id) {
return; // already restored for this character, into the VM that is live now
}
let who = format!("{} on {}", id.1, id.0);
file.path = crate::local_state::chat_character_path(&id.0, &id.1);
*file.identity.get(&script) = Some(id);
*file.dirty.get(&script) = false;
*file.identity.get(script) = Some(id);
*file.dirty.get(script) = false;
file.last_change = None;
let text = file
.path
@@ -450,20 +557,22 @@ fn load_chat_looks(
}
});
let had_file = text.is_some();
let mut parsed = text
.map(|t| parse(&t, &shortcut_rows(&channels)))
.unwrap_or_default();
// A file our own pre-2120 writer damaged: no marker, so its `ZONECHANNELS` words may have been
// composed from an empty roster and its window channel lists erased with them. Repaired below,
// once — the next save stamps the marker.
let damaged = text
.as_deref()
.is_some_and(|t| !t.contains(WRITER_GENERATION));
let mut parsed = text.map(|t| parse(&t, &rows)).unwrap_or_default();
// The DBC seed — every `ChatChannels.dbc` row the client joins by itself (`flags & 1`).
let seed_mask = auto_rows.iter().fold(0, |m, (_, id)| m | zone_bit(*id));
if !had_file {
// The loader's no-file path (§3, `0x4997ad`): window 1's channel slots get every
// `ChatChannels.dbc` row the client joins by itself (`flags & 1`) as `(Shortcut, id)` —
// the rows `ChatFrame_RegisterForChannels` will match zone speech against by id. The
// rest of the record is the boot init the VM already holds.
// The loader's no-file path (§3, `0x4997ad`): the mask is seeded from those rows, and
// window 1's channel slots get each of them as `(Shortcut, id)` — the rows
// `ChatFrame_RegisterForChannels` will match zone speech against by id. The rest of the
// record is the boot init the VM already holds.
let mut general = ChatWindowLook::stock(0);
general.channels = channels
.channels
.auto_join_rows()
.map(|r| (r.shortcut.clone(), r.id))
.collect();
general.channels = auto_rows.clone();
parsed.looks.push((0, general));
}
if !parsed.looks.is_empty() || !parsed.colors.is_empty() {
@@ -474,6 +583,38 @@ fn load_chat_looks(
parsed.joined.len()
);
}
if damaged {
// **The one-time repair** (decision 2120, [`WRITER_GENERATION`]). Re-seed window 1 the way
// the loader's no-file path seeds it, and OR the DBC bits back into the mask. Additive and
// deduplicated, exactly like the in-window `ZONECHANNELS` arm (`0x499332`-`0x4994e7`), so a
// file that survived intact is left alone and one that was zeroed gets its channels back.
if let Some((_, general)) = parsed.looks.iter_mut().find(|(w, _)| *w == 0) {
for (shortcut, id) in &auto_rows {
if !general
.channels
.iter()
.any(|(c, _)| c.eq_ignore_ascii_case(shortcut))
{
general.channels.push((shortcut.clone(), *id));
}
}
}
parsed.zone_mask = Some(parsed.zone_mask.unwrap_or(0) | seed_mask);
// Owed a write, so the repair is genuinely ONCE: the next save composes the repaired
// record and stamps [`WRITER_GENERATION`], and this branch never runs for the character
// again. `last_change` is `None` here, so that save is the very next frame's.
*file.dirty.get(script) = true;
info!("chat cache: repaired a pre-2120 file's zone channels for {who}");
}
// …and `ChatWindowFile` is finished with, so the mask can go home to `ChannelState`.
drop(file);
// The mask is durable state from here on (decision 2120): the file's word when it carried one,
// the DBC seed when it did not, and from then on the confirmed joins' own OR.
let mask = parsed.zone_mask.unwrap_or(seed_mask);
if let Some(mut channels) = world.get_resource_mut::<super::edit::ChannelState>() {
channels.zone_mask = mask;
}
script.set_guild_recruitment_mode(u8::from(parsed.guild_recruitment_auto));
script.set_chat_colors(parsed.colors);
script.set_chat_window_looks(parsed.looks);
// §8: UPDATE_CHAT_WINDOWS once, then UPDATE_CHAT_COLOR for every registry entry, on the file
@@ -493,11 +634,13 @@ fn load_chat_looks(
}
// The header's CHANNELS: the custom channels the character was in, re-joined the way the
// reference re-joins them at login (the zone channels are the zone walk's, not the file's).
for name in parsed.joined {
let _ = commands.0.send(ClientCommand::JoinChannel {
name,
password: String::new(),
});
if let Some(commands) = commands {
for name in parsed.joined {
let _ = commands.send(ClientCommand::JoinChannel {
name,
password: String::new(),
});
}
}
}
@@ -506,7 +649,10 @@ fn watch_chat_looks(script: Option<NonSendMut<UiScript>>, mut file: ResMut<ChatW
let Some(mut script) = script else { return };
let moved = !script.take_chat_window_changes().is_empty();
let coloured = script.take_chat_color_changes();
if !(moved || coloured) {
// `SetGuildRecruitmentMode` is a Lua write into the same file (decision 2115) — a host seat at
// login does not arm this, only a script call does.
let recruitment = script.take_guild_recruitment_change();
if !(moved || coloured || recruitment) {
return;
}
*file.dirty.get(&script) = true;
@@ -518,6 +664,8 @@ fn write(script: &UiScript, channels: &super::edit::ChannelState, path: &std::pa
&script.chat_window_looks(),
&script.chat_colors(),
&roster(channels),
channels.zone_mask,
script.guild_recruitment_mode() != 0,
);
if let Err(e) = crate::local_state::write_atomic(path, &body) {
warn!("chat cache: cannot write {}: {e}", path.display());
@@ -565,21 +713,15 @@ fn save_on_session_end(
pub(super) fn plugin(app: &mut App) {
app.init_resource::<ChatWindowFile>()
// **The restore is not here.** `UPDATE_CHAT_WINDOWS`/`UPDATE_CHAT_COLOR` have to precede
// the session's first chat line and `PLAYER_LOGIN` respectively, and no `run_if` can buy
// that: 1978's `not(ingame_ui_pending)` gate put the restore on exactly the frame the
// parked VM comes back and `feed_chat` drains the whole queued login burst, with nothing
// ordering the two. It is called from `ui_script::lifecycle`'s world-entry load instead —
// [`restore_chat_looks`], decision 2119. Only the watcher belongs in `Update`.
.add_systems(
Update,
(load_chat_looks, watch_chat_looks)
.chain()
.run_if(in_state(crate::char_select::ClientState::InWorld))
// …and never before the in-game UI exists (1348's law, the unit feed's own
// words): the restore fires `UPDATE_CHAT_WINDOWS` once and latches a per-VM
// memo, and the VM that is live between the world-entry edge and the deferred
// entry load is the SAME object the load then fills — so a restore made in
// that window reaches no chat frame and its memo blocks the one that would.
// Every plate texture stayed at the XML's white, alpha 1 (director report,
// 2026-09-04).
.run_if(bevy::ecs::schedule::common_conditions::not(
crate::ui_script::ingame_ui_pending,
)),
watch_chat_looks.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.add_systems(
Update,
@@ -662,7 +804,9 @@ mod tests {
("Trade - City".to_string(), 2),
("MyChan".to_string(), 0),
];
let text = render(&looks, &colors, &joined);
// The mask is the character's durable one now (2120), passed in rather than derived —
// here, the two zone channels the roster holds.
let text = render(&looks, &colors, &joined, 0b11, true);
assert!(
text.contains("\nCHANNELS\nMyChan\nEND\n\nZONECHANNELS 3\n"),
"{text}"
@@ -696,16 +840,108 @@ mod tests {
fn a_windows_zone_bits_are_masked_by_the_joined_set() {
let mut w = look(1, 0, 0, 0, 0, 0);
w.channels = vec![("General".into(), 1), ("Trade".into(), 2)];
let text = render(&[w], &[], &[("General - Elwynn Forest".to_string(), 1)]);
// The mask holds General alone — Trade was explicitly left, so the window's own Trade
// bit is ANDed away on the way out.
let text = render(
&[w],
&[],
&[("General - Elwynn Forest".to_string(), 1)],
1,
true,
);
let windows: Vec<&str> = text.split("WINDOW 1").collect();
assert!(windows[1].contains("ZONECHANNELS 1\n"), "{text}");
}
/// **The bug 2120 fixes, in one assertion: an empty roster must not erase a window's
/// channels.**
///
/// The mask used to be `joined.fold(|m, (_, id)| m | zone_bit(id))`, so a save taken while
/// `ChannelState::joined` was momentarily empty — the session-end flush racing
/// `end_session_channels` on the same unordered `OnExit(InWorld)` edge — wrote `ZONECHANNELS
/// 0` in the header AND, through `window bits AND header mask`, in every window block. Nothing
/// but the loader's no-file path ever seeds a window's channel list, so the next login rebuilt
/// window 1 with none, and the stock `ChatFrame_OnEvent` silently drops every `CHANNEL*` line
/// a window does not carry: no `Joined Channel:` notices and no General/Trade speech, for
/// good. Ten of the twenty files in this repo's own config folder had reached that state.
#[test]
fn an_empty_roster_does_not_erase_a_windows_zone_channels() {
let mut w = look(1, 0, 0, 0, 0, 0);
w.channels = vec![("General".into(), 1), ("Trade".into(), 2)];
// The character IS in both channels — the mask says so — but the live roster is empty,
// which is exactly the state a session-end save can be taken in.
let text = render(&[w], &[], &[], 0b11, true);
assert!(
text.contains("\nZONECHANNELS 3\n"),
"the header carries the durable mask, not the roster's shadow: {text}"
);
let parsed = parse(&text, &rows());
assert_eq!(parsed.zone_mask, Some(0b11), "the header word round-trips");
assert_eq!(
parsed.looks[0].1.channels,
vec![("General".to_string(), 1), ("Trade".to_string(), 2)],
"window 1 keeps both channels — pre-2120 this came back empty and stayed empty"
);
}
/// A file our own pre-2120 writer zeroed carries no [`WRITER_GENERATION`] line, and that is
/// the whole discriminator: the repair is keyed on the marker, not on guessing whether a
/// player meant to leave every zone channel.
#[test]
fn a_written_file_carries_the_writer_generation_and_a_damaged_one_does_not() {
let text = render(&[ChatWindowLook::stock(0)], &[], &[], 0b11, true);
assert!(
text.contains(WRITER_GENERATION),
"every file we write is stamped, so the repair fires once: {text}"
);
// The shape the damage takes: a header the reference's own reader accepts, with no marker.
let damaged = "VERSION 2\n\nCHANNELS\nEND\n\nZONECHANNELS 0\n\n\
WINDOW 1\nSIZE 0\nSHOWN 1\n\nMESSAGES\nSYSTEM\nEND\n\n\
CHANNELS\nEND\n\nZONECHANNELS 0\n\nEND\n";
assert!(!damaged.contains(WRITER_GENERATION));
let parsed = parse(damaged, &rows());
assert_eq!(parsed.zone_mask, Some(0), "the zeroed header parses as 0");
assert!(
parsed.looks[0].1.channels.is_empty(),
"and window 1 comes back with no channels — the symptom"
);
}
/// The mask is durable state: a confirmed join sets a bit, an explicit leave clears one, and
/// a custom channel (no DBC id) has no bit at all.
#[test]
fn the_zone_mask_moves_on_join_and_explicit_leave_only() {
let mut state = super::super::edit::ChannelState {
channels: benilla_formats::ChatChannelsCatalog::from_rows(vec![
benilla_formats::ChatChannelRow {
id: 1,
flags: 0x11,
pattern: "General - %s".into(),
shortcut: "General".into(),
},
benilla_formats::ChatChannelRow {
id: 2,
flags: 0x3b,
pattern: "Trade - %s".into(),
shortcut: "Trade".into(),
},
]),
..Default::default()
};
state.note_zone_channel_joined("General - Elwynn Forest");
state.note_zone_channel_joined("Trade - City");
assert_eq!(state.zone_mask, 0b11);
state.note_zone_channel_joined("MyChan");
assert_eq!(state.zone_mask, 0b11, "a custom channel has no bit");
state.note_zone_channel_left("Trade - City");
assert_eq!(state.zone_mask, 0b01, "an explicit leave clears one bit");
}
/// The header is a comment block and survives the round trip as one — a reader that choked on
/// its own header would lose the player's settings on the second launch.
#[test]
fn the_header_is_skipped_not_parsed() {
assert!(render(&[ChatWindowLook::default()], &[], &[]).starts_with('#'));
assert!(render(&[ChatWindowLook::default()], &[], &[], 0, true).starts_with('#'));
assert_eq!(parse(HEADER, &rows()), Parsed::default());
}
@@ -788,7 +1024,7 @@ mod tests {
locked: false,
..look(1, 0, 0, 0, 64, 14)
};
let text = render(std::slice::from_ref(&unlocked), &[], &[]);
let text = render(std::slice::from_ref(&unlocked), &[], &[], 0, true);
assert!(text.contains("LOCKED 0"));
assert_eq!(parse(&text, &rows()).looks, vec![(0, unlocked.clone())]);
assert_eq!(
@@ -807,7 +1043,7 @@ mod tests {
docked: Some(3),
..look(1, 0, 0, 0, 0, 0)
};
let text = render(std::slice::from_ref(&moved), &[], &[]);
let text = render(std::slice::from_ref(&moved), &[], &[], 0, true);
assert!(text.contains("DOCKED 3"));
assert_eq!(parse(&text, &rows()).looks, vec![(0, moved.clone())]);
// No DOCKED/SHOWN/MESSAGES at all: the boot init stands — window 1 shown and undocked
+128
View File
@@ -2406,3 +2406,131 @@ fn the_free_professions_line_is_printed_once() {
"LEVEL_UP_SKILL_POINTS_P1, plural-picked by GetText on cp2"
);
}
// ───────────── The chat cache restores INSIDE the login, not after it (decision 2119) ─────────
/// **The login order, asserted at the two places that broke.**
///
/// `UPDATE_CHAT_WINDOWS` is the only thing that registers a chat frame for any `CHAT_MSG_*`
/// (`ChatFrame_OnEvent`'s arm calls `ChatFrame_RegisterForMessages(GetChatWindowMessages(id))`),
/// and the `UPDATE_CHAT_COLOR` burst mirrors `WHISPER` into `ChatTypeInfo["REPLY"]`, whose `.id`
/// is 0 — the same id every `AddMessage` with no explicit colour carries — so the burst repaints
/// them. Both events come from the chat-cache restore, so the restore has to be finished before
/// `PLAYER_LOGIN`: before it, an addon's `Print` gets repainted whisper-pink, and any chat routed
/// in that window lands on a frame registered for nothing and is dropped in silence (1784).
///
/// Pre-2119 the restore was an `Update` system and this probe saw `windows = nil`,
/// `colors = nil`, `registered = ""` at `PLAYER_LOGIN`.
///
/// The probe is planted in the boot VM the way `world_entry_tests` plants its addon: the entry
/// load runs onto the VM that already exists, so a frame created here hears the whole load.
#[test]
fn the_chat_cache_restore_is_finished_before_player_login() {
let _data = benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-chat-order-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("benilla-config")).expect("hermetic home");
let _capture = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let _home = crate::local_state::test_env::EnvGuard::set(
"BENILLA_HOME",
tmp.join("benilla-config")
.to_str()
.expect("utf-8 temp path"),
);
let mut world = bevy::prelude::World::new();
world.init_resource::<crate::ui_script::AddOnIdentity>();
world.init_resource::<crate::minimap::MinimapZoom>();
world.init_resource::<crate::ui_script::ReloadUiPending>();
world.init_resource::<super::edit::ChannelState>();
world.init_resource::<super::settings::ChatWindowFile>();
crate::ui_script::setup_script(&mut world);
world
.non_send_resource::<benilla_ui::script::UiScript>()
.run(
r#"
ChatOrderProbe = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UPDATE_CHAT_WINDOWS")
f:RegisterEvent("UPDATE_CHAT_COLOR")
f:RegisterEvent("PLAYER_LOGIN")
f:SetScript("OnEvent", function()
if event == "UPDATE_CHAT_WINDOWS" then
ChatOrderProbe.windows = (ChatOrderProbe.windows or 0) + 1
elseif event == "UPDATE_CHAT_COLOR" then
ChatOrderProbe.colors = (ChatOrderProbe.colors or 0) + 1
else
ChatOrderProbe.loginWindows = ChatOrderProbe.windows or 0
ChatOrderProbe.loginColors = ChatOrderProbe.colors or 0
ChatOrderProbe.loginRegistered =
(ChatFrame1 and ChatFrame1.messageTypeList
and table.concat(ChatFrame1.messageTypeList, ",")) or ""
end
end)
"#,
)
.expect("order probe");
world.insert_resource(crate::char_select::Roster::with_pending_pick(
vec![benilla_protocol::Character {
guid: 1,
name: "Probeorder".into(),
race: 1, // Human → Alliance
class: 1, // Warrior
gender: 0,
level: 60,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
zone: 0,
map: 0,
position: benilla_protocol::wire::Vector3d {
x: 0.0,
y: 0.0,
z: 0.0,
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
pet_display_id: 0,
pet_level: 0,
pet_family: 0,
}],
1,
));
crate::ui_script::load_ingame_ui_on_world_entry(&mut world);
let read = |expr: &str| -> String {
world
.non_send_resource::<benilla_ui::script::UiScript>()
.eval::<Option<String>>(&format!("return tostring({expr})"))
.ok()
.flatten()
.unwrap_or_default()
};
assert_eq!(
read("ChatOrderProbe.loginWindows"),
"1",
"UPDATE_CHAT_WINDOWS must have fired before PLAYER_LOGIN — it is the only thing that \
registers a chat frame for CHAT_MSG_*, so a line routed before it is dropped in silence"
);
assert_ne!(
read("ChatOrderProbe.loginColors"),
"0",
"the UPDATE_CHAT_COLOR burst must precede PLAYER_LOGIN — after it, its WHISPER→REPLY \
mirror repaints every already-printed AceConsole line whisper-pink"
);
assert!(
read("ChatOrderProbe.loginRegistered").contains("SYSTEM"),
"ChatFrame1 must carry the SYSTEM message group at PLAYER_LOGIN, not {:?}",
read("ChatOrderProbe.loginRegistered")
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
@@ -205,14 +205,17 @@ fn load_action_bar(s: &UiScript) {
super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\ReputationFrame.xml");
// The options window: `LOCK_ACTIONBAR` and `ALWAYS_SHOW_MULTIBARS` are declared there, as the
// reference declares them in UIOptionsFrame_Init (1938) — and the manifest loads it before
// the bars.
// The dialog engine — the keybindings page registers its two confirms into its table (1960).
super::test_ui::load_ui(s, r"Interface\FrameXML\BasicControls.xml"); // `TEXT`
super::test_ui::load_ui(s, r"Interface\FrameXML\LocaleProperties.lua"); // `GetText`
super::test_ui::load_ui(s, r"Interface\FrameXML\StaticPopup.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\UIDropDownMenu.xml");
// `LOCK_ACTIONBAR` and `ALWAYS_SHOW_MULTIBARS` are declared by `UIOptionsFrame_Init` — the
// reference's own home for them, and off the chain since 2115 (they were our
// `OptionsFrame.xml`'s from 1938 until then). The manifest loads this before the bars and
// before our window, whose Action Bars rows capture the value as their Defaults; so does this.
super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrame.lua");
super::test_ui::load_ui(s, r"Interface\FrameXML\UIOptionsFrame.xml");
super::test_ui::load_ui(s, "ScrollTemplates.xml");
super::test_ui::load_ui(s, "KeyBindingsPage.xml");
super::test_ui::load_ui(s, "OptionsFrame.xml");
@@ -307,6 +310,79 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Why a restarted `GetTime` clock reads on the bar as "no cooldown at all"** (decision 2116),
/// through the shipped `Cooldown.lua`: `CooldownFrame_SetTimer`'s only gate is
/// `start > 0 and duration > 0 and enable > 0`, and its `else` branch is `this:Hide()`. So the
/// SAME running cooldown draws or vanishes purely on which clock its start was converted against.
///
/// This is the observable half of the relog bug: the store held the cooldown (the press was still
/// refused), the feed pushed a triple every frame, and the button showed nothing — because the VM
/// had been rebuilt and its clock had gone back to zero, putting every already-running cooldown's
/// start behind the new epoch.
#[test]
fn a_start_behind_the_clocks_epoch_hides_the_stock_sweep() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
kind: 0x00,
action: 133,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0); // GetTime == 10 — a clock that restarted ten seconds ago
// A 10-minute cooldown armed 30 s ago, converted against that restarted clock: start = −20 s.
s.set_action_state(
1,
Some(ActionState {
usable: true,
cooldown: Some((-20_000, 600_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
assert_eq!(
super::test_ui::cooldown_play(&s, "ActionButton1Cooldown"),
None,
"the stock `start > 0` guard hides the pane outright — 9.5 minutes still to run and the \
button shows nothing"
);
// The same cooldown on a clock that never restarted: GetTime 100, armed at 70. The sweep is
// exactly where it belongs.
s.tick(90.0);
s.set_action_state(
1,
Some(ActionState {
usable: true,
cooldown: Some((70_000, 600_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
assert_eq!(
super::test_ui::cooldown_play(&s, "ActionButton1Cooldown"),
Some((0, 50)),
"30 s of 600 s elapsed ⇒ sequence 0 scrubbed to 5 %: 50 ms of the 1000 ms sweep"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The pie paints OVER its button's art. The Cooldown child is born at button-level+1, and the
/// draw key's LEVEL term outranks 0884's bucket-wide layer term — so the sweep quad must sort
/// after the icon (BACKGROUND) and after the button's own special textures. A regression here is
@@ -1505,6 +1581,11 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"KeyBindingsPage.xml",
// `UIOptionsFrame_Init`'s uvars and `UIOptionsFrameCheckButtons`, which
// `MultiActionBars.xml` below writes into at its load — the reference's own l.21 seat,
// ahead of our window and ahead of the bars (2115).
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
] {
+141 -5
View File
@@ -56,6 +56,7 @@ fn install_boot_vm(world: &mut World) {
return;
}
};
seed_vm_clock(world, &mut script);
install_addon_asset_resolvers(world, &mut script);
load_global_strings(world, &script);
load_emote_tokens(world, &script);
@@ -67,6 +68,39 @@ fn install_boot_vm(world: &mut World) {
world.insert_non_send_resource(script);
}
/// **`GetTime()` is the PROCESS's clock, not the VM's** (decision 2116) — so a VM built now starts
/// it where the process already is, and [`UiClock`] is re-anchored to match in the same breath.
///
/// The reference's `GetTime` (`0x515ea0`) reads `KERNEL32!GetTickCount` and scales by 0.001 (the
/// thunk `0x42c010` → `0x42b790`, wow-re's `core` boundary row): an OS clock, with no relationship
/// to the Lua VM, that cannot restart. Ours is a Lua global the VM owns, and since 1290/1291 the VM
/// is destroyed and rebuilt at every logout/login and every `ReloadUI` — so without this seed the
/// clock went back to zero on each of those, and every host value already converted onto it
/// (`CooldownInfo::ui_triple` and its kin, the aura feed's `expirationTime`) suddenly sat in the
/// past. Stock `Cooldown.lua`'s `CooldownFrame_SetTimer` gates on `start > 0` and takes the `else`
/// branch — `this:Hide()` — for anything else, so after a relog **every cooldown still running
/// drew nothing** while the store, and therefore the cast validator, still held it: no sweep on the
/// button and a "Spell is not ready yet" on the press.
///
/// `Time<Real>` is the right clock because it is the very one [`UiClock`] already anchors on:
/// `elapsed` is measured at `last_update()`, which is [`UiClock::anchor`], so the pair written here
/// is atomic in exactly the sense that resource's doc requires. A world with no `Time<Real>` (a
/// bare test world) starts at zero, which is what it did before this existed.
fn seed_vm_clock(world: &mut World, script: &mut UiScript) {
let (anchor, elapsed) = world
.get_resource::<Time<bevy::time::Real>>()
.map_or_else(Default::default, |t| {
(t.last_update(), t.elapsed_secs_f64())
});
script.set_now(elapsed);
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
*clock = UiClock {
anchor: anchor.unwrap_or_else(std::time::Instant::now),
ui_now: elapsed,
};
}
}
/// Wire up the halves of `Interface\AddOns\` **art and fonts** (decisions 1322, 2103): the sprite
/// decoder's **loose-file root** (so addon-shipped BLP/TGA files render at all — the store's
/// [`benilla_assets::WorldAssets::set_loose_addon_root`]), the VM's **texture probe** (so the
@@ -329,6 +363,27 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
let identity = world
.get_resource::<crate::char_select::Roster>()
.and_then(crate::ui_macro::identity);
// **The CVar table goes in BEFORE the UI loads**, for the same reason and with a shipped-file
// consumer rather than an addon one (decision 2115): the reference's own `UIOptionsFrame.xml`
// — hidden, on the manifest for the addons that name it — reads `cameraSmoothStyle` and
// `cameraSmoothTrackingStyle` inside its two camera dropdowns' `OnLoad`, and a nil there is a
// concat error, not a default.
//
// The seed normally belongs to `cvars::sync_cvars`, a per-VM `Update` claim (1291), and on a
// FIRST login that has run many frames earlier. **The window is a VM replacement**: a
// `/reloadui` builds a fresh VM and loads the whole interface inside one call, before `Update`
// gets a turn — so this edge would load a client with no CVar table at all. Nothing had ever
// noticed, because until now no interface file read a CVar at load.
//
// The two lines are `sync_cvars`'s own first two, in its own order, and the order is
// load-bearing: the saved config goes in FIRST so registration starts each key at the player's
// value rather than the factory one (1291) — reversed, a reload would quietly reset every
// knobless CVar to its default. `sync_cvars` still does its full knob-derived pass on the next
// `Update`; both calls are idempotent, and a re-register never clobbers a live value.
if let Some(persist) = world.get_resource::<crate::cvars::CvarPersist>() {
script.set_cvar_saved_base(persist.saved_base());
}
script.register_cvars(crate::cvars::registered_pairs());
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
@@ -377,6 +432,16 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// back. Startup always precedes this state edge (1038), so the knob is already loaded.
let zoom = world.resource::<crate::minimap::MinimapZoom>();
script.set_minimap_zoom(zoom.outdoor, zoom.inside);
// **The chat cache restores HERE — after every chat frame exists, before anything prints into
// one** (decision 2119). It is the sole firer of `UPDATE_CHAT_WINDOWS`, which is the only
// thing that registers a chat frame for any `CHAT_MSG_*` (ref `ChatFrame.lua` l.1261-1273),
// and of the `UPDATE_CHAT_COLOR` burst, whose `WHISPER`→`REPLY` mirror repaints every line
// already in the window that was printed with no explicit colour (`ChatTypeInfo["REPLY"].id`
// is 0, and so is such a line's). As an `Update` system both landed too late: the login MOTD
// was routed to a window registered for nothing, and every Ace2 addon's `Print` at
// `PLAYER_LOGIN` came out whisper-pink. Placed after `load_ingame_ui` so an ADDON's chat frame
// is registered too, and before `finish_ui_load` so the burst precedes `PLAYER_LOGIN`.
crate::ui_chat::restore_chat_looks(world, &mut script);
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults, before
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
@@ -569,8 +634,14 @@ pub(crate) fn end_ui_session(world: &mut World) {
// handles itself — it is keyed on [`benilla_ui::script::UiScript::session`] ([`VmMemo`]) — but
// these are plain values other systems read through `Res<…>`, with no VM in hand to key
// against, so the edge clears them. Each is a fact about a frame tree that is about to stop
// existing: a hovered frame id, the minimap's extracted hole, a payload the cursor is carrying,
// and the VM-relative clock the cooldown conversions run through.
// existing: a hovered frame id, the minimap's extracted hole, and a payload the cursor is
// carrying.
//
// **[`UiClock`] is NOT one of them, and used to be** (decision 2116). It reads like a fact
// about the dying VM — it is the `GetTime` leg of the conversion pair — but `GetTime` is the
// reference's OS tick count, not a per-VM clock, so zeroing it here restarted every cooldown
// and aura conversion at the character screen. [`seed_vm_clock`] writes the pair for the VM
// that replaces this one, moments below.
//
// The two input latches were cleared by `char_select`'s logout and disconnect handlers, one
// copy each. They belong here: the reason they need clearing is that `feed_ui_input` stops
@@ -588,9 +659,6 @@ pub(crate) fn end_ui_session(world: &mut World) {
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
minimap.0 = None;
}
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
*clock = UiClock::default();
}
install_boot_vm(world);
}
@@ -826,4 +894,72 @@ mod tests {
assert_eq!(vm.session(), session, "the same VM, no session moved");
assert!(world.get_non_send_resource::<ParkedBootVm>().is_none());
}
/// **A rebuilt VM inherits the running `GetTime()` clock, and so does the conversion pair**
/// (decision 2116) — the bug the director reported as *cooldowns are lost on relog*.
///
/// The reference's `GetTime` is `KERNEL32!GetTickCount` × 0.001 (`0x515ea0` → `0x42c010` →
/// `0x42b790`), an OS clock that cannot restart — which is why stock `Cooldown.lua` can gate
/// on `start > 0`. Ours lives in the VM, and since 1290/1291 the VM dies at the character
/// screen; before this, `end_ui_session` zeroed [`UiClock`] too, so on the way back in every
/// cooldown that was already running converted to a NEGATIVE start,
/// `CooldownFrame_SetTimer` took its `else` branch and hid the sweep — while the store, and
/// therefore the cast validator, still held the cooldown.
///
/// Against the old shape the two assertions read *"the new VM's GetTime clock restarted at 0
/// instead of the process's 90 s"* and *"a cooldown armed before the rebuild derived start
/// -30000 ms; stock Cooldown.lua hides anything but `start > 0`"*.
#[test]
fn a_rebuilt_vm_inherits_the_running_gettime_clock() {
use std::time::{Duration, Instant};
let mut world = World::new();
world.init_resource::<UiClock>();
// The process has been up 90 s — the clock `Time<Real>` keeps and the one the dying VM
// is on, which are the same clock.
let startup = Instant::now() - Duration::from_secs(90);
let mut time = Time::<bevy::time::Real>::new(startup);
// `Time<Real>` measures `elapsed` from its FIRST update, not from `startup` — so the app's
// own clock is "since frame one", and the test's has to be seeded the same way.
time.update_with_instant(startup);
time.update_with_instant(startup + Duration::from_secs(90));
world.insert_resource(time);
let mut dying = UiScript::new().unwrap();
dying.set_now(90.0);
world.insert_non_send_resource(dying);
// The session never loaded an in-game UI, so the shutdown tail (which writes the player's
// four files) is skipped: this test is about the VM swap and nothing else.
world.init_resource::<PendingEntryUiLoad>();
end_ui_session(&mut world);
let vm = world
.get_non_send_resource::<UiScript>()
.expect("the session end installs a fresh boot VM");
assert_eq!(
vm.now(),
90.0,
"the new VM's GetTime clock restarted at {} instead of the process's 90 s",
vm.now()
);
// …and the pair every `Instant`→`GetTime` conversion runs through moved with it: a
// 10-minute cooldown armed 30 s before the rebuild still derives its real start.
let clock = world.resource::<UiClock>();
let armed = crate::cooldowns::CooldownInfo {
start: startup + Duration::from_secs(60),
remaining_ms: 570_000,
duration_ms: 600_000,
enabled: true,
};
let triple = armed
.ui_triple(clock.anchor, clock.ui_now)
.expect("a running cooldown pushes a triple");
assert_eq!(
triple,
(60_000, 600_000, true),
"a cooldown armed before the rebuild derived start {} ms; stock Cooldown.lua hides \
anything but `start > 0`",
triple.0
);
}
}
@@ -207,6 +207,18 @@ end
/// the addon harness ([`crate::addon_harness`]), which needs our entire interface under each
/// surveyed addon.
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
// **The client's CVar table first, because the interface reads CVars AT LOAD** (decision
// 2115). The app registers `crate::cvars::REGISTERED` at startup, long before world entry, so
// in the running client this call finds every name already there and only refreshes its
// default — it never clobbers a live value ([`benilla_ui::script::UiScript::register_cvars`]).
// What it buys is that a **probe** VM is the same client: `UiScript::new()` carries only
// `benilla-ui`'s own CVars, and the stock `UIOptionsFrame.xml`'s two camera dropdowns read
// theirs inside their own `OnLoad`
// (`getglobal("OPTION_TOOLTIP_CAMERA"..UIDropDownMenu_GetSelectedID(this))`, which is nil and
// then a concat error when `GetCVar("cameraSmoothStyle")` answers nil). Both of those CVars
// have been registered here with real consumers since 1493/1502; the probes simply never had
// them, and 218 tests found that out the hour this row went on the manifest.
script.register_cvars(crate::cvars::registered_pairs());
// **Silent, the way the client's own load is.** `0x48fbf0` brackets ITSELF in the counted
// sound-suppression scope — `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on
// exit — across the TOC walk, Bindings.xml and the AddOns, so both of its callers (login
+11 -2
View File
@@ -235,14 +235,23 @@ pub(crate) struct UiInput;
/// shape) re-measures the tick→feed scheduling gap every frame and wobbles the derived start by
/// that jitter (±12 ms observed live), turning every running cooldown into a per-frame "changed"
/// triple — the diff churn 0375 existed to kill.
///
/// **Both legs run on the PROCESS's clock and neither restarts with the VM** (decision 2116). A
/// rebuilt VM (any logout/login, any `ReloadUI`) is handed the running clock by
/// [`lifecycle::seed_vm_clock`], which writes this pair in the same breath — the reference's
/// `GetTime` is `KERNEL32!GetTickCount`, an OS clock, and stock `Cooldown.lua` gates on
/// `start > 0`, so a clock that went back to zero at the character screen pushed every already-
/// running cooldown into the past and hid its sweep.
#[derive(Resource)]
pub(crate) struct UiClock {
/// The `Instant` leg: `Time<Real>::last_update()` at the tick that produced [`Self::ui_now`].
pub(crate) anchor: std::time::Instant,
/// The `GetTime` leg: the VM clock's value after that tick (seconds).
/// The `GetTime` leg: the clock's value after that tick (seconds since this process started).
pub(crate) ui_now: f64,
}
/// The pre-boot pair, replaced by [`lifecycle::seed_vm_clock`] the moment the first VM exists —
/// `init_resource` needs it, nothing else should build one.
impl Default for UiClock {
fn default() -> Self {
Self {
@@ -473,7 +482,7 @@ pub(crate) use lifecycle::{
// Consumed only from other modules' test code (the emote-table checks, the harness's UI-init
// tail, the quit-once pin) — a plain re-export would warn unused in a non-test build.
#[cfg(test)]
use lifecycle::load_ingame_ui_on_world_entry;
pub(crate) use lifecycle::load_ingame_ui_on_world_entry;
use lifecycle::shutdown_on_exit;
#[cfg(test)]
pub(crate) use lifecycle::{
@@ -39,10 +39,14 @@ fn load_action_bar(s: &UiScript) {
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
// The stock multibar file's OnLoad writes `UIOptionsFrameCheckButtons`, which our options
// window publishes — the reference's own load order (UIOptionsFrame.xml l.21 before
// MultiActionBars.xml l.39), and the manifest's since 1938.
// The stock multibar file's OnLoad writes `UIOptionsFrameCheckButtons`, whose home is the
// reference's own hidden Interface Options window (2115) — the reference's own load order
// (UIOptionsFrame.xml l.21 before MultiActionBars.xml l.39), and the manifest's since 1938.
// That window is also where `ALWAYS_SHOW_MULTIBARS` is declared and where the load arm this
// file exercises lives.
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
@@ -912,14 +916,16 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
// counts nobody raised below zero. (Ours used to guard the no-op; the guard went with the
// file, 1938.)
// The load arm, both ways: off touches no count, on opens the wells.
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" OptionsFrame_ApplySavedSettings()")
.unwrap();
// The LOAD arm is the reference's own VARIABLES_LOADED arm now (`UIOptionsFrame.lua`
// l.218-220), off the chain since 2115 — not our retired `OptionsFrame_ApplySavedSettings`.
s.run("ALWAYS_SHOW_MULTIBARS = \"0\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
assert!(well(&s));
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
assert!(!well(&s), "a load with the option off left the count alone");
s.run("ALWAYS_SHOW_MULTIBARS = \"1\" OptionsFrame_ApplySavedSettings()")
.unwrap();
s.run("ALWAYS_SHOW_MULTIBARS = \"1\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
assert!(well(&s), "a load with the option on opens the wells");
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" MultiActionBar_UpdateGridVisibility()")
.unwrap();
@@ -61,6 +61,13 @@ fn harness_on(mut s: UiScript) -> UiScript {
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
// The reference's own Interface Options window, hidden — this kit's source of truth for
// `UIOptionsFrameCheckButtons`/`UIOptionsFrameSliders`, which our own file used to
// transcribe and 2115 deleted. Its widget kit and the video window's shared helpers come
// with it, in the manifest's own order.
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"ScrollTemplates.xml", // the Keybindings page's faux-scroll kit
"KeyBindingsPage.xml", // the Keybindings body's templates + script (1008)
"OptionsFrame.xml",
@@ -688,7 +695,7 @@ fn a_slider_rows_readout_sits_on_its_labels_line() {
/// The Audio harness: the real registered CVar set on the table before the XML loads, exactly
/// the app's boot order (register → seed → load → select).
fn audio_harness() -> UiScript {
let mut s = UiScript::new().unwrap();
let s = UiScript::new().unwrap();
s.register_cvars(crate::cvars::registered_pairs());
s
}
@@ -884,6 +891,13 @@ fn actionbars_harness() -> UiScript {
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"KeyBindingsPage.xml",
// The reference's own Interface Options window BEFORE ours, as the manifest has it
// (2115) — `UIOptionsFrame_Init` is what assigns `LOCK_ACTIONBAR` and
// `ALWAYS_SHOW_MULTIBARS`, and this page's rows capture those at their own OnLoad as
// the Defaults value. It is also the home of `UIOptionsFrameCheckButtons`, which
// `MultiActionBars.xml` below writes into at its load.
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
],
@@ -3642,7 +3656,7 @@ fn the_chat_page_writes_the_hover_delay_global_and_the_loot_spam_cvar() {
/// fresh VM has re-run `ChatFrame.xml`'s file-scope `"0"`.
#[test]
fn a_saved_hover_delay_is_applied_when_the_variables_land() {
let s = chat_harness();
let mut s = chat_harness();
// What the saved-variables chunk does: assign the global, then the window's VARIABLES_LOADED.
s.run("REMOVE_CHAT_DELAY = \"1\"").unwrap();
assert_eq!(
@@ -3651,7 +3665,10 @@ fn a_saved_hover_delay_is_applied_when_the_variables_land() {
(0.2, 0.15),
"the bare assignment changes nothing on its own — that is why the row has an applyFunc"
);
s.run("OptionsFrame_ApplySavedSettings()").unwrap();
// The reference's own VARIABLES_LOADED arm, off the chain since 2115 — this row's apply is
// `SetChatMouseOverDelay(REMOVE_CHAT_DELAY)` at `UIOptionsFrame.lua:216`, and the event is
// what runs it. (It used to be our own `OptionsFrame_ApplySavedSettings` walk.)
s.fire_event("VARIABLES_LOADED", vec![]);
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
@@ -4285,6 +4302,18 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
"PetMeleeDamage",
"`combat_text::law::PET_MELEE_DAMAGE`, a const bool — see CombatDamage",
),
// …and the SLIDERS' half, which this census was blind to until 2115 extended it from
// `UIOptionsFrameCheckButtons` to `UIOptionsFrameSliders` as well. One row, and it is not
// cosmetic: `UIOptionsFrame_Load` does `slider:SetValue(GetCVar(value.cvar))`, and
// `Slider:SetValue` is a shape-A binding (`0x790980`, wow-re `numeric-arg-coercion-law.md`)
// that RAISES on a nil in the reference too — so this is what stops the stock window's
// `_Load()` running clean, and pfUI's GVAR path with it.
(
"cameraYawMoveSpeed",
"the Mouse Look Speed slider — benilla's mouse-look yaw rate is not a CVar-driven knob \
yet; its sibling `cameraYawSmoothSpeed` (auto-follow) is registered and drives \
`follow.yaw_speed`, and this one has no counterpart in `player::camera`",
),
];
/// **Every CVar the reference's own options table names is registered here, or listed above with
@@ -4298,16 +4327,22 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
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.
// **BOTH of the reference's option tables** (2115). The checkbuttons were the whole census
// until the stock window went on the manifest and `UIOptionsFrameSliders` turned out to name a
// CVar nothing here registers — a row the check could not see because it only read one table.
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 \
for _, v in ipairs(UIOptionsFrameSliders) do \
if v.cvar then table.insert(out, v.cvar) end \
end \
table.sort(out) \
return out",
)
.expect("read UIOptionsFrameCheckButtons");
.expect("read UIOptionsFrameCheckButtons and UIOptionsFrameSliders");
assert!(
named.len() >= 28,
"the transcription lost rows: only {} cvar entries",
@@ -32,10 +32,6 @@ use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
fn ui_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui")
}
/// The manifest prefix these two files sit on: fonts, `UIParent` (the parent every addon passes),
/// `HideUIPanel` (the close button's OnClick), the tooltip (the options widgets' hover) and the
/// scroll kit (`ScrollFrame_OnLoad`), then the two files under test in manifest order.
@@ -57,7 +53,13 @@ fn harness() -> UiScript {
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
"OptionsFrameTemplates.xml",
// `UIOptionsCheckButtonTemplate`'s home. It was ours, in a one-template
// `OptionsFrameTemplates.xml`, until 2115 put the reference's own hidden Interface
// Options window on the manifest — the template's actual home, and the whole point of
// that record: an addon that names it gets the reference's own declaration. The dropdown
// kit comes with it because the window has four.
r"Interface\FrameXML\UIDropDownMenu.xml",
r"Interface\FrameXML\UIOptionsFrame.xml",
] {
load_xml(&s, file);
}
@@ -434,59 +436,18 @@ fn the_options_check_button_resolves_its_whole_inheritance_chain() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **No invented names.** Every `virtual="true"` template these two files declare is a name the
/// real 1.12.1 client has, read from `reference/1.12-globals.tsv`.
///
/// Scoped to these two files on purpose: the rest of `assets/ui` is full of deliberately
/// benilla-shaped template names (`BenillaScrollBarTemplate`, `BenillaScriptLogRowTemplate`,
/// `OptionsRedButtonTemplate`), and a whole-tree sweep would be asserting something else. These
/// two files make the opposite claim — *these are the reference's own names, which is why an addon
/// can find them* — so that claim is the one worth gating. A template renamed to something
/// plausible-but-absent here is a template no addon will ever name.
#[test]
fn every_template_these_files_declare_is_a_real_1_12_name() {
let _data = benilla_formats::wow_data_or_skip!();
let tsv =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../reference/1.12-globals.tsv");
let text = std::fs::read_to_string(&tsv).unwrap_or_else(|e| {
panic!(
"reading {}: {e} — regenerate with scripts/gen-reference-globals.py",
tsv.display()
)
});
let known: std::collections::HashSet<&str> = text
.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| l.split('\t').next())
.collect();
let mut checked = 0;
// OUR files only. This reads the source tree directly, so it cannot name a chain entry — and
// it has no reason to: asserting that the reference's own file declares 1.12 names is
// tautological. What it guards is us inventing one (decision 1841).
// Our remaining file only. `UIPanelTemplates.xml` was ours until 1846 put the whole kit on the
// chain; what is left to guard is the one template we still declare.
for file in ["OptionsFrameTemplates.xml"] {
let src = std::fs::read_to_string(ui_dir().join(file)).unwrap();
let doc = benilla_ui::framexml::parse(&src).unwrap();
for item in &doc.items {
let benilla_ui::framexml::TopLevel::Template(el) = item else {
continue;
};
let name = el.name().expect("every template here is named");
assert!(
known.contains(name),
"{file} declares '{name}', which is not a 1.12 global — \
a template the reference does not have is one no addon can name"
);
checked += 1;
}
}
assert!(
checked >= 1,
"only {checked} templates swept — the sweep, not the files, is what broke"
);
}
// **RETIRED with its subject (decision 2115).** `every_template_these_files_declare_is_a_real_1_12_name`
// swept the `virtual="true"` templates of the files we shipped under a reference name and required
// each to be a real 1.12 global (`reference/1.12-globals.tsv`) — the guard against inventing a
// plausible-but-absent template name that no addon could ever reach for (decision 1841). 1846 took
// `UIPanelTemplates.xml` to the chain and left it one subject, `OptionsFrameTemplates.xml`'s single
// `UIOptionsCheckButtonTemplate`; 2115 took that one too, by loading the reference's own
// `UIOptionsFrame.xml`. Nothing under `assets/ui` now declares a template under a reference name —
// what is left there is deliberately benilla-shaped (`BenillaScrollBarTemplate`,
// `OptionsCheckboxRowTemplate`, `BenillaScriptLogRowTemplate`), which is a different claim and the
// sweep's own doc said so. This is 1751 §5 working as written: a drift instrument loses its subject
// as the copies retire. `the_options_check_button_resolves_its_whole_inheritance_chain` above still
// proves the template resolves — off the chain now.
/// **`PanelTemplates_TabResize`'s `tab` argument is OPTIONAL, and omitting it means `this`.**
///
@@ -1575,6 +1575,46 @@ mod tests {
"a registered 1.12 binding whose body is uncarved (wow-re `bindings.md`, structural row only); a \
wow-re orchestrator is out on it and it is built when the carve lands (1960). Reached by TRADE_REPLACE_ENCHANT's Accept, an event this engine does not fire yet.",
),
(
"OptionsFrame.lua",
"GetCurrentResolution",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"GetGamma",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"GetRefreshRates",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"GetScreenResolutions",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"RestartGx",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"RestoreVideoDefaults",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"SetGamma",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"SetScreenResolution",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
];
let mut missing: Vec<(String, String)> = Vec::new();
+415 -1
View File
@@ -7,13 +7,24 @@
//! invisible to the chain's own files too, because a Lua caller that uses one value of two never
//! notices the second.
//!
//! **Arity is only half a shape.** A binding that answers the right *number* of values of the
//! wrong *kind* is the same silent break one level down, and it is the shape the 1.12 API is most
//! exposed to: the reference has no boolean-returning query at all — every `Is*`/`Can*`/`Has*` row
//! in the table is `(nil) | (number)`, because the client pushes `1` and `nil`, never `true` and
//! `false`. An addon writing `if IsShiftKeyDown() == 1` — and 87 corpus addons compare returns to
//! `1` — reads a `true` as "not held". So the kinds column is gated as well, wherever the table
//! says it is trustworthy (decision 2118).
//!
//! The table is wow-re's `re/audit/binding-shapes.tsv`, vendored: 1722 rows over all 82 registrar
//! tables, one per registered binding, generated and differentially tested against that repo's
//! other harvester. It carries the gate rule **per row** rather than in prose, which is the whole
//! reason it can be enforced instead of remembered:
//!
//! * `arity_conf = exact` — sound. This is the column to gate on.
//! * `kinds_conf` — trustworthy only where an independent push count agreed; not gated here.
//! * `kinds_conf = agree` — the row's independent `lua_push*` trace matched `eax` at every `ret`,
//! which is the table's own statement that the kinds are trustworthy. Gated too, by
//! [`every_query_binding_answers_the_reference_s_return_kinds`]; a row that did not agree is
//! advisory and this module never reads its kinds at all.
//! * `argc_conf` — **never** gate on it. It is a deliberate over-flagging superset: any binding
//! whose direct callee reads a Lua index is marked `lower-bound`, so a `lower-bound` row says
//! nothing about the real argument count.
@@ -54,6 +65,36 @@
/// an entry needs an arity, a reason, and a dispatched question.
const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
/// The **kind** gate's own shrinking list, same rules as [`NOT_YET_ASSERTED`]: a name, the wrong
/// kinds it currently answers, and the dispatched question that will settle it. An entry whose
/// binding has come into agreement fails the gate, so it cannot outlive its fix.
///
/// Both entries are edge-case *values* the shapes table states the kind of and the recorded wow-re
/// notes state no value for, dispatched into wow-re as one batched question (decision 2118):
///
/// * `GetSendMailItem` — slots 3 and 4 are `number` on every alternative including the
/// `(nil,nil,…)` empty one, so the no-attachment leg pushes two numbers. Which two is a byte
/// question; ours answers `(nil, nil, 1, nil)` and the slot-4 nil is provably wrong.
const KINDS_NOT_YET_ASSERTED: &[(&str, &str, &str)] = &[(
"GetSendMailItem",
"nil,nil,number,nil",
"the reference's no-attachment leg pushes numbers in slots 3 and 4; the two values are dispatched into wow-re (GetSendMailItem 0x4ae590)",
)];
/// The widget kind gate's shrinking list — see [`KINDS_NOT_YET_ASSERTED`].
///
/// `GetFont`'s height slot is `number` in the reference's single alternative, and ours answers nil
/// on a FontString that has never been given a font. wow-re's `font-object-lua-surface.md` §9.3
/// records that the FontString reads back through its resolved `CGxFont`, so a NULL one yields nil
/// for the path and the flags — but it does not say what the *height* slot then carries, and that
/// is the value the corpus does arithmetic on (`aux-addon`'s font-resize wheel handler,
/// `tabs/search/frame.lua:481`). Dispatched.
const WIDGET_KINDS_NOT_YET_ASSERTED: &[(&str, &str, &str)] = &[(
"GetFont",
"nil,nil,string",
"the reference's height slot is a number even with no CGxFont resolved; the value is dispatched into wow-re (CSimpleFontString::GetFont 0x79d400)",
)];
/// The widget half's own shrinking list — see [`NOT_YET_ASSERTED`], same rules. Also empty:
/// `GetFogColor` came off it with 1845, which found the fog colour was a packed `0xAARRGGBB` dword
/// all along.
@@ -67,6 +108,9 @@ struct Row {
name: String,
table_va: String,
arity: usize,
/// The `kinds` column, verbatim — `(number,string?)`, or several `|`-separated alternatives.
/// Empty when the row's `kinds_conf` is not `agree`; see [`KindGate`].
kinds: String,
}
fn rows() -> Vec<Row> {
@@ -87,6 +131,14 @@ fn rows() -> Vec<Row> {
name: f[0].to_string(),
table_va: f[3].to_string(),
arity: f[7].parse().ok()?,
// ADVISORY unless `kinds_conf == agree` — the table's own column contract. A row
// that did not agree carries no kinds here at all, so nothing downstream can read
// one by accident.
kinds: if f.len() > 10 && f[10] == "agree" {
f[9].to_string()
} else {
String::new()
},
})
})
.collect()
@@ -343,3 +395,365 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
mismatches.join("\n ")
);
}
/// Split a `kinds` cell into its alternatives, each one a per-slot kind list.
///
/// `(number,string?)` is one alternative of two slots; `(nil) | (number)` is two of one. An
/// arity-0 row is `()` and yields nothing, which is how the kind gates skip the verbs that answer
/// no values at all.
fn kind_alternatives(cell: &str) -> Vec<Vec<String>> {
cell.split('|')
.map(|alt| {
alt.trim()
.trim_start_matches('(')
.trim_end_matches(')')
.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect::<Vec<String>>()
})
.filter(|a| !a.is_empty())
.collect()
}
/// Does one observed Lua `type()` satisfy one reference slot?
///
/// Two entries in the vocabulary assert less than they look like they do, and both are the table's
/// own doing rather than ours:
///
/// * **`string?` is string-or-nil.** `lua_pushstring 0x6f3890` begins `test edx,edx ; jne` and
/// tail-jumps to `lua_pushnil` when the `char*` is NULL, so a push instruction is not proof that
/// a string reached Lua. The harvester emits plain `string` only where the pointer is a constant
/// resolving to a readable literal; everything else is `string?`. This is *why* a miss path can
/// show a fixed arity with no kind alternation and still answer nil in that slot.
/// * **`any` / `value`** are the slots the walk could not narrow. They accept everything.
///
/// Everything else is exact — and note what is *absent* from the vocabulary across all 1503 rows
/// the table calls trustworthy: two `boolean` slots in the entire client, `IsPetAttackActive` and
/// Lua's own `rawequal`. A 1.12 predicate answers `1` or `nil`.
fn slot_accepts(want: &str, got: &str) -> bool {
match want {
"any" | "value" => true,
"string?" => got == "string" || got == "nil",
w => w == got,
}
}
/// The Lua helper both kind gates probe through: answer the `type()` of every value a call
/// returned, in order, comma-joined. A table constructor would swallow the trailing nils, so the
/// count has to come from the vararg itself.
///
/// It reads `arg.n`, not `select('#', ...)`, and that is not a style choice: **`...` as a value is
/// a syntax error in this VM**, because 5.0 has no `TK_DOTS` arm in `simpleexp` and the parser
/// patch reproduces that (1192; `lua50::tests::the_vararg_expression_is_a_syntax_error_as_it_is_on_1_12`).
/// The 5.0 spelling is the implicit `arg` table, which is what all 177 stock FrameXML files use.
const KIND_HELPER: &str = "function PGKinds(...) \
local o = {} for i = 1, arg.n do o[i] = type(arg[i]) end \
return table.concat(o, ',') end";
/// Every registered global whose return *kinds* the reference states trustworthily, answered by
/// this client (decision 2118).
///
/// Same three narrowings as the arity gate, for the same reasons: query verbs only, a call that
/// raises is skipped, and a name registered from two tables is read at its non-glue row. A fourth
/// is specific to kinds — a row whose observed arity already disagrees is left to the arity gate,
/// because comparing a 3-tuple against a 4-slot reference reports the same fault twice.
#[test]
fn every_query_binding_answers_the_reference_s_return_kinds() {
let all = rows();
let mut by_name: std::collections::HashMap<&str, Vec<&Row>> = std::collections::HashMap::new();
for r in &all {
by_name.entry(&r.name).or_default().push(r);
}
let mut s = benilla_ui::script::UiScript::new().expect("VM");
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Shapeprobe".into()),
level: 60,
..Default::default()
}),
);
s.run(KIND_HELPER).expect("kind helper");
let mut checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
for (name, rs) in &by_name {
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
.iter()
.any(|p| name.starts_with(p))
{
continue;
}
let candidates: Vec<&&Row> = if rs.len() == 1 {
rs.iter().collect()
} else {
rs.iter().filter(|r| r.table_va != GLUE_TABLE).collect()
};
let Some(first) = candidates.first() else {
continue;
};
if candidates.iter().any(|r| r.kinds != first.kinds) {
continue;
}
let want = kind_alternatives(&first.kinds);
if want.is_empty() {
continue;
}
let call = if name.starts_with("Unit") {
format!(r#"{name}("player")"#)
} else {
format!("{name}()")
};
let probe = format!(
"if type({name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({call}) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
// An arity fault is the arity gate's to report; reporting it here too would double-count.
if !want.iter().any(|alt| alt.len() == got.len()) {
continue;
}
checked += 1;
let agrees = want.iter().any(|alt| {
alt.len() == got.len()
&& alt
.iter()
.zip(&got)
.all(|(w, g)| slot_accepts(w.as_str(), g))
});
let observed = got.join(",");
if !agrees {
if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
assert_eq!(
*known, observed,
"{name} is on the not-yet-asserted list at stale kinds — it now answers ({observed})"
);
continue;
}
mismatches.push(format!(
"{name}: answers ({observed}), reference states {}",
first.kinds
));
} else {
assert!(
!KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name),
"{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED"
);
}
}
// A floor, not a target — see the arity gate's. Raise it when coverage rises; never lower it
// to make a change fit.
assert!(
checked >= 150,
"the kind gate measured only {checked} bindings — it has stopped covering anything"
);
assert!(
mismatches.is_empty(),
"{} of {checked} probed bindings answer values of the wrong kind:\n {}",
mismatches.len(),
mismatches.join("\n ")
);
}
/// The widget half of the kind gate — same rules, probed on an instance of each method's own
/// registrar class (see [`WIDGET_PROBES`]).
#[test]
fn every_widget_method_answers_the_reference_s_return_kinds() {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-shapes.tsv"
);
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(KIND_HELPER).expect("kind helper");
let mut made: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
for (table, kind, name) in WIDGET_PROBES {
if s.run(&format!(
"{name} = CreateFrame(\"{kind}\", \"{name}\", UIParent)"
))
.is_ok()
{
made.insert(table, (*name).to_string());
}
}
for (table, expr) in REGION_PROBES {
let var = format!("PGR{}", made.len());
if s.run(&format!("{var} = {expr}")).is_ok() {
made.insert(table, var);
}
}
let mut checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
for line in text
.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
{
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 11 || f[4] != "widget" || f[8] != "exact" || f[10] != "agree" {
continue;
}
let (name, table) = (f[0], f[3]);
if !["Get", "Is", "Has", "Can", "Num"]
.iter()
.any(|p| name.starts_with(p))
{
continue;
}
let Some(obj) = made.get(table) else { continue };
let want = kind_alternatives(f[9]);
if want.is_empty() {
continue;
}
let probe = format!(
"if type({obj}.{name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({obj}:{name}()) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
if !want.iter().any(|alt| alt.len() == got.len()) {
continue;
}
checked += 1;
let agrees = want.iter().any(|alt| {
alt.len() == got.len()
&& alt
.iter()
.zip(&got)
.all(|(w, g)| slot_accepts(w.as_str(), g))
});
let observed = got.join(",");
if !agrees {
if let Some((_, known, _)) = WIDGET_KINDS_NOT_YET_ASSERTED
.iter()
.find(|(n, ..)| *n == name)
{
assert_eq!(
*known, observed,
"{name} is on the widget not-yet-asserted list at stale kinds — it now answers ({observed})"
);
continue;
}
mismatches.push(format!(
"{obj}:{name} answers ({observed}), reference states {}",
f[9]
));
}
}
assert!(
checked >= 40,
"the widget kind gate measured only {checked} methods — it has stopped covering anything"
);
assert!(
mismatches.is_empty(),
"{} of {checked} probed widget methods answer values of the wrong kind:\n {}",
mismatches.len(),
mismatches.join("\n ")
);
}
/// Argument tuples the boolean gate tries, in order, until one call returns.
///
/// The kind gate probes a binding with nothing (or a unit token) and skips the ones that raise,
/// which leaves half the query surface unmeasured — the reference gates its arguments and raises
/// on a missing one (1834/1836). A *boolean* answer is wrong whatever the arguments are, so this
/// gate can afford a ladder the kind gate cannot: a wrong-but-plausible argument can change which
/// kind tuple comes back, and would make the kind gate cry wolf, but it can never make `1` into
/// `true`.
const ARG_LADDER: &[&str] = &["", "\"player\"", "1", "1, 1", "\"player\", 1"];
/// **1.12 has no boolean query.** Across all 1503 rows this table calls trustworthy, exactly two
/// slots are `boolean`: `IsPetAttackActive` and Lua's own `rawequal`. Everything else that reads
/// as a predicate — every `Is*`, `Can*`, `Has*` — is `(nil) | (number)`, because the client pushes
/// `1` and `nil`.
///
/// The distinction is invisible to `if x then` and decisive to everything else: `x == 1`,
/// `tostring(x)`, a value stored in a saved variable, arithmetic, a table key. `ColorPickerPlus`
/// writes `if IsShiftKeyDown() == 1 then` (decision 2118), and no gate we had could see it.
#[test]
fn no_query_binding_answers_a_lua_boolean() {
// The reference's own two, by name — the list may only shrink.
const REFERENCE_BOOLEANS: &[&str] = &["IsPetAttackActive", "rawequal"];
let all = rows();
let mut s = benilla_ui::script::UiScript::new().expect("VM");
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Shapeprobe".into()),
level: 60,
..Default::default()
}),
);
s.run(KIND_HELPER).expect("kind helper");
let mut names: Vec<&str> = all
.iter()
.map(|r| r.name.as_str())
.filter(|n| {
["Get", "Is", "Has", "Can", "Unit", "Num"]
.iter()
.any(|p| n.starts_with(p))
})
.filter(|n| !REFERENCE_BOOLEANS.contains(n))
.collect();
names.sort_unstable();
names.dedup();
let mut checked = 0usize;
let mut booleans: Vec<String> = Vec::new();
for name in names {
for args in ARG_LADDER {
let probe = format!(
"if type({name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({name}({args})) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
checked += 1;
if got.split(',').any(|k| k == "boolean") {
booleans.push(format!("{name}({args}) answers ({got})"));
}
break;
}
}
assert!(
checked >= 300,
"the boolean gate measured only {checked} bindings — it has stopped covering anything"
);
assert!(
booleans.is_empty(),
"{} bindings answer a Lua boolean; 1.12 pushes 1/nil and only {REFERENCE_BOOLEANS:?} are \
boolean in the reference:\n {}",
booleans.len(),
booleans.join("\n ")
);
}
@@ -1528,3 +1528,183 @@ fn the_shipped_manifest_opens_the_macro_icon_picker() {
"the picker is up"
);
}
/// **The reference's own Interface Options window is on the manifest, and it stays HIDDEN**
/// (decision 2115) — the whole shape of that record, asserted in one place.
///
/// Four claims, and each one has a way to fail that nothing else here would catch:
///
/// 1. **`UIOptionsFrame` is the reference's frame, not an alias onto ours.** The alias
/// (`UIOptionsFrame = OptionsFrame`) is what this replaces, and under it every one of these
/// reads would have succeeded while pointing at the window the player opens — pfUI's skin does
/// `UIOptionsFrame:SetWidth(1024)`. So the test asks for the reference's own CHILDREN, which an
/// alias could never grow.
/// 2. **It is never shown.** `hidden="true"` is the stock file's own attribute and nothing of ours
/// may `Show()` it.
/// 3. **`UIOptionsFrameCheckButtons` carries the five rows `MultiActionBars.lua:10` writes** —
/// which IS the load-order proof. That file writes them at its own load, under the reference's
/// own comment *"Hack to get around load order dependencies"*, so the rows are there only if
/// the options window's manifest row sits above the bars', as `FrameXML.toc` l.21 vs l.39 has
/// it. Reorder the manifest and this is what goes red.
/// 4. **Ours is still the player's.** `GameMenuButtonOptions` opens `OptionsFrame`.
#[test]
fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefive".into()),
level: 60,
..Default::default()
}),
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// 1 · the reference's own frame, by the children only it declares.
for name in [
"UIOptionsFrame",
"UIOptionsFrameTitle",
"UIOptionsFrameTab1",
"UIOptionsFrameTab2",
"UIOptionsFrameOkay",
"UIOptionsFrameCancel",
"UIOptionsFrameDefaults",
"UIOptionsFrameResetTutorials",
"UIOptionsFrameCheckButton1",
"UIOptionsFrameCheckButton69",
"UIOptionsFrameSlider1",
"UIOptionsFrameSlider4",
"UIOptionsFrameClickCameraDropDown",
"UIOptionsFrameCameraDropDown",
"UIOptionsFrameTargetofTargetDropDown",
"UIOptionsFrameCombatTextDropDown",
"BasicOptions",
"BasicOptionsGeneral",
"BasicOptionsDisplay",
"BasicOptionsCamera",
"BasicOptionsHelp",
"AdvancedOptions",
"AdvancedOptionsActionBars",
"AdvancedOptionsChat",
"AdvancedOptionsRaid",
"AdvancedOptionsCombatText",
] {
assert!(
s.eval::<bool>(&format!("return getglobal({name:?}) ~= nil"))
.unwrap(),
"{name} — pfUI's options-interface skin walks every one of these"
);
}
assert!(
s.eval::<bool>("return UIOptionsFrame ~= OptionsFrame")
.unwrap(),
"the alias is gone: the stock frame is its own frame, not ours under a second name"
);
// 2 · …and hidden, by its own file's attribute.
assert!(
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
"the stock window must never be on screen"
);
// 3 · the order proof.
let rows: Vec<String> = s
.eval::<Vec<String>>(
"local out = {} \
for _, k in ipairs({ \"SHOW_MULTIBAR1_TEXT\", \"SHOW_MULTIBAR2_TEXT\", \
\"SHOW_MULTIBAR3_TEXT\", \"SHOW_MULTIBAR4_TEXT\", \"ALWAYS_SHOW_MULTIBARS_TEXT\" }) do \
local row = UIOptionsFrameCheckButtons[k] \
if row and row.func and row.setFunc then table.insert(out, k) end \
end \
return out",
)
.expect("UIOptionsFrameCheckButtons is a table with rows");
assert_eq!(
rows.len(),
5,
"MultiActionBars.lua:10 writes five rows into UIOptionsFrameCheckButtons at ITS load, so \
the options window's manifest row must sit above the bars' — got {rows:?}"
);
// 4 · ours is still the window the ESC menu opens.
assert_eq!(
s.eval::<String>(
"return GameMenuButtonOptions:GetScript(\"OnClick\") and \"bound\" or \"\""
)
.unwrap(),
"bound"
);
s.run("GameMenuButtonOptions:Click()").unwrap();
assert!(
s.eval::<bool>("return OptionsFrame:IsShown()").unwrap(),
"the player's Options button opens OUR window"
);
assert!(
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
"…and never the stock one"
);
}
/// **pfUI's `UIOptionsFrame_Save()` path runs clean, and `_Load()` stops at exactly one thing**
/// (decision 2115).
///
/// pfUI's `modules/gui.lua` l.146-148 wraps a GVAR checkbox's write in
/// `UIOptionsFrame_Load()` … `UIOptionsFrame_Save()`, so both are reached at runtime by a real
/// addon and both had to be more than nil.
///
/// `_Save` runs clean. `_Load` does not, and the failure is **pinned rather than hidden** because
/// it names a real gap this record surfaced: `UIOptionsFrameSliders` row 3 is
/// `cameraYawMoveSpeed`, which `cvars::REGISTERED` does not carry, and `_Load` does
/// `slider:SetValue(GetCVar(value.cvar))` — `Slider:SetValue` is a shape-A binding (`0x790980`,
/// wow-re `numeric-arg-coercion-law.md`) that raises on a nil in the reference too. The row is
/// listed in [`super::options_tests`]'s unbacked census with its blocker; the day something
/// registers it, THIS assertion goes red and gets deleted, which is the point of writing it as an
/// expectation rather than an ignore.
#[test]
fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefive".into()),
level: 60,
..Default::default()
}),
);
assert!(super::load_default_ui(&s).is_empty());
// The Okay path, whole: 69 check buttons, four sliders, four dropdowns, the multibar toggles
// and the combat-text tail. Driven through the reference's OWN caller — `UIOptionsFrameOkay`'s
// `<OnClick>` (xml l.1205-1209) — rather than as a bare call, and that is not ceremony: the
// `SHOW_PARTY_PETS` arm reaches `RefreshBuffs`, whose first act is `this.hasDispellable = nil`
// (`BuffFrame.lua:266`), so the reference's ambient handler global has to be set. It is, at
// every real call site: the Okay button here, and pfUI's own checkbox on its GVAR path.
s.run("UIOptionsFrameOkay:Click()")
.expect("the stock Okay button's own handler");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
"…and the window it hides on the way out was hidden to begin with"
);
let err = s
.run("this = UIOptionsFrameOkay UIOptionsFrame_Load()")
.expect_err("_Load stops at the one unregistered slider CVar — see the doc comment");
let err = err.to_string();
assert!(
err.contains("SetValue"),
"the ONLY thing that may stop _Load is slider 3's nil CVar; got: {err}"
);
assert!(
s.eval::<bool>(r#"return GetCVar("cameraYawMoveSpeed") == nil"#)
.unwrap(),
"…and that is the cause: cameraYawMoveSpeed is the unregistered one"
);
}
+79 -3
View File
@@ -119,6 +119,13 @@ pub(super) fn load_ui_no_warnings(s: &UiScript, entry: &str) -> usize {
}
fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bool) -> usize {
// **A kit's VM is the client's VM, and that includes the CVar table** (decision 2115). The app
// registers `crate::cvars::REGISTERED` at startup, before any interface file loads;
// `UiScript::new()` carries only `benilla-ui`'s own. The stock `UIOptionsFrame.xml` reads two
// camera CVars inside its dropdowns' `OnLoad` and raises on a nil, so a kit that skips this is
// measuring a client that does not exist. Idempotent, and it never clobbers a value a test set
// first — `register_cvars` only refreshes an existing slot's default.
s.register_cvars(crate::cvars::registered_pairs());
let path = entry.replace('\\', "/");
let bytes = read(&path).unwrap_or_else(|| panic!("{entry}: not found"));
if path.to_ascii_lowercase().ends_with(".lua") {
@@ -129,6 +136,17 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
let doc = benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes))
.unwrap_or_else(|e| panic!("{entry}: {e}"));
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
// Seated BEFORE the load, not after it like the two below: `MultiActionBarFrame_OnLoad`
// indexes `UIOptionsFrameCheckButtons` from inside this very load walk, where the micro row's
// and UIParent's callees only run later (decision 2115).
if path
.rsplit('/')
.next()
.is_some_and(|l| l.eq_ignore_ascii_case("MultiActionBars.xml"))
{
s.run(MULTI_ACTION_BAR_STAND_INS)
.expect("the multibar stand-ins");
}
let report = benilla_ui::loader::load_in(s, &doc, &path, &provider);
assert!(
report.errors.is_empty(),
@@ -143,6 +161,10 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
if leaf.eq_ignore_ascii_case("UIParent.xml") && super::reference_ui::is_chain_entry(&path) {
s.run(UIPARENT_STAND_INS).expect("the UIParent stand-ins");
}
if leaf.eq_ignore_ascii_case("UIOptionsFrame.xml") {
s.run(UIOPTIONS_STAND_INS)
.expect("the stock options window's stand-ins");
}
if no_warnings {
assert!(
report.warnings.is_empty(),
@@ -279,6 +301,60 @@ pub(super) const UIPARENT_STAND_INS: &str = r#"
end
"#;
/// **What a kit owes the stock `MultiActionBars.xml`.** Its `MultiActionBarFrame_OnLoad`
/// (`MultiActionBars.lua:10`, under the reference's own comment *"Hack to get around load order
/// dependencies"*) writes five rows into `UIOptionsFrameCheckButtons` — a table whose home is
/// `UIOptionsFrame.xml`, which the reference's toc seats at l.21, eighteen rows above this one.
/// The shipped manifest has that order (decision 2115) and needs nothing here; a KIT is a prefix
/// of the manifest and dozens of them load the bars without any options window.
///
/// `or {}` rather than a fresh table, and seated at load rather than on first use, for the reason
/// 1988 gives: a table, like a function, is a plain global write, so a real declaration that lands
/// afterwards simply wins — and in manifest order it never lands afterwards, which is exactly what
/// the shipped `UIOptionsFrameCheckButtons` order proof asserts. A kit that loads both in manifest
/// order therefore gets the real table with the bars' five rows in it, which is the client's own
/// state.
/// **What a kit owes the stock `UIOptionsFrame.xml`.** Its `UIOptionsFrame_OnEvent`'s
/// VARIABLES_LOADED arm (`UIOptionsFrame.lua` l.193-227) is the reference's own load-time ladder —
/// the one decision 2115 retired our re-expression of — and it calls six functions unguarded:
/// `BuffButtons_UpdatePositions`, `FCF_Set_SimpleChat`, `FCF_Set_ChatLocked`,
/// `SetChatMouseOverDelay`, `MultiActionBar_ShowAllGrids` and
/// `RaidOptionsFrame_UpdatePartyFrames`. In the shipped manifest that is fine twice over: the
/// buff bar loads far above this row, the chat and raid files far below it, and the event does not
/// fire until every one of them has loaded. A KIT is a prefix of the manifest and fires the event
/// itself, so it needs the names to exist.
///
/// Functions, so load-time seating is safe and the kit's order does not matter: a later chunk's
/// `function X()` is a plain global write that overwrites the stand-in, unlike a frame's
/// non-overwriting publish (1988's reasoning, and its precedent).
pub(super) const UIOPTIONS_STAND_INS: &str = r#"
BuffButtons_UpdatePositions = BuffButtons_UpdatePositions or function() end
FCF_Set_SimpleChat = FCF_Set_SimpleChat or function() end
FCF_Set_NormalChat = FCF_Set_NormalChat or function() end
FCF_Set_ChatLocked = FCF_Set_ChatLocked or function() end
SetChatMouseOverDelay = SetChatMouseOverDelay or function() end
MultiActionBar_ShowAllGrids = MultiActionBar_ShowAllGrids or function() end
RaidOptionsFrame_UpdatePartyFrames = RaidOptionsFrame_UpdatePartyFrames or function() end
-- …and the one its own CHECK BUTTONS reach: three of them register VARIABLES_LOADED for
-- themselves (xml l.373, 925, 993), and CheckButton43's arm calls `PartyFrame.lua`'s
-- `UpdatePartyMemberBackground`. The other two call the file's own dropdown loaders.
UpdatePartyMemberBackground = UpdatePartyMemberBackground or function() end
"#;
pub(super) const MULTI_ACTION_BAR_STAND_INS: &str = r#"
UIOptionsFrameCheckButtons = UIOptionsFrameCheckButtons or {}
-- The five ROWS it writes into, not just the table: the reference's hack assigns
-- `UIOptionsFrameCheckButtons["SHOW_MULTIBAR1_TEXT"].setFunc`, which needs the row to exist.
-- Empty rows on purpose — the real ones carry `index = 33..36, 40`, and a stand-in that
-- restated those numbers would be a transcription of the reference's table in a test helper,
-- which is the thing 2115 deleted from `OptionsFrame.xml`. Any kit that reads an index has the
-- real window loaded and therefore the real table.
for _, key in ipairs({ "SHOW_MULTIBAR1_TEXT", "SHOW_MULTIBAR2_TEXT", "SHOW_MULTIBAR3_TEXT",
"SHOW_MULTIBAR4_TEXT", "ALWAYS_SHOW_MULTIBARS_TEXT" }) do
UIOptionsFrameCheckButtons[key] = UIOptionsFrameCheckButtons[key] or {}
end
"#;
pub(super) const MICRO_BUTTON_STAND_INS: &str = r#"
local real = UpdateMicroButtons
function UpdateMicroButtons()
@@ -513,10 +589,10 @@ pub(super) const CHARACTER_UI: &[&str] = &[
// The reference's own window tab (`CharacterFrameTabButtonTemplate`), whose `<OnShow>`
// fits each tab to its text — it needs the `UIPanelTemplates` pair above it (1993).
r"Interface\FrameXML\CharacterFrameTemplates.xml",
// The four options templates off the chain, then ours for the one it does not carry
// (`UIOptionsCheckButtonTemplate` — decision 1841).
// The four options templates off the chain — ReputationFrame's detail check boxes inherit
// `OptionsCheckButtonTemplate`. Ours beside it is gone with 2115: its one template,
// `UIOptionsCheckButtonTemplate`, comes off the chain out of `UIOptionsFrame.xml` now.
"Interface\\FrameXML\\OptionsFrameTemplates.xml",
"OptionsFrameTemplates.xml", // ReputationFrame's detail check boxes
"Interface\\FrameXML\\CharacterFrame.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
"Interface\\FrameXML\\PetPaperDollFrame.xml",
@@ -287,7 +287,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
/// table so the pass means what it says.
#[test]
fn action_button_hover_takes_the_default_corner() {
let mut s = harness(&[
let s = harness(&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
+3 -1
View File
@@ -115,7 +115,9 @@ fn spell_tooltip_view(
.store
.map_or(d.mana_cost, |s| crate::ui_action::usable::power_cost(d, s));
let cost = {
let div = if d.power_type == 1 { 10 } else { 1 };
// The one `0x6e7130` table, not a local `if power_type == 1` — decision 2117 found three
// hand-rolled copies of it and one of them had been applied at a single site out of four.
let div = benilla_protocol::messages::power_display_scale(d.power_type);
let unit = match d.power_type {
0 => "Mana",
1 => "Rage",
+1 -1
View File
@@ -1135,7 +1135,7 @@ mod tests {
fn the_v_key_mirrors_into_the_cvar_table() {
use crate::bindings::{cmd, BindingsState};
let mut app = App::new();
let mut script = benilla_ui::script::UiScript::new().unwrap();
let script = benilla_ui::script::UiScript::new().unwrap();
script.register_cvars([(CVAR_ENEMIES, "0"), (CVAR_FRIENDS, "0")]);
app.add_systems(Update, toggle_vplates)
.init_resource::<VPlateMode>()
@@ -295,12 +295,14 @@ impl PartyMemberStatsInfo {
/// [`power_display_scale`], exactly as the live-object leg divides. Without it an out-of-range
/// warrior's rage bar reads ten times an in-range one's. Miss ⇒ `0`, the binding's own.
pub fn shown_power(&self) -> u32 {
u32::from(self.cur_power.unwrap_or(0)) / power_display_scale(self.shown_power_type())
u32::from(self.cur_power.unwrap_or(0))
/ power_display_scale(u32::from(self.shown_power_type()))
}
/// `UnitManaMax` on the record path (`0x5178af`), the same divide.
pub fn shown_max_power(&self) -> u32 {
u32::from(self.max_power.unwrap_or(0)) / power_display_scale(self.shown_power_type())
u32::from(self.max_power.unwrap_or(0))
/ power_display_scale(u32::from(self.shown_power_type()))
}
}
+5 -4
View File
@@ -257,10 +257,11 @@ pub use trade::{
pub use trainer::{train_fail, trainer_buy_spell, trainer_list, trainer_spell_state, TrainerSpell};
pub use tutorial::{tutorial_flag, TutorialFlags};
pub use update_object::{
quest_slot_state, CorpseLook, CreateSpline, MovementBlock, MoverState, Object, ObjectFields,
ObjectType, OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE,
AURA_FLAG_EFF_INDEX_MASK, FIELD_PLAYER_SKILL_INFO_1_1, PLAYER_EXPLORED_ZONES_SLOTS,
PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
power_display_scale, quest_slot_state, CorpseLook, CreateSpline, MovementBlock, MoverState,
Object, ObjectFields, ObjectType, OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot,
AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, FIELD_PLAYER_SKILL_INFO_1_1,
PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS,
UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
};
pub use vendor::{
buy_item, buy_item_in_slot, buy_result, buyback_item, list_inventory, repair_item, sell_item,
@@ -26,15 +26,30 @@ const STAND_STATE_DEAD: u8 = 7;
/// ×1000 (vmangos `GetCreatePowers`: `POWER_RAGE → 1000`, `POWER_HAPPINESS → 1050000`), so the real
/// client's 100 rage and 1050 happiness are this divide, not a different field.
///
/// Applied by `UnitMana`/`UnitManaMax` only — the raw accessors stay raw, and the pet happiness
/// bucket thresholds are on the RAW scale, which is why both forms have to exist (decision 1034).
/// **This is a whole-client law, not one getter's private step** (decision 2117). `0x6e7130` is
/// called from *every* place a power figure becomes something a player reads, and the wire number
/// is raw at all of them:
///
/// **Two callers, because the getters have two sources** (decision 1640): the live descriptor
/// legs below, and the party roster record's ([`crate::messages::PartyMemberStatsInfo::shown_power`])
/// — `UnitMana 0x517670` divides by this on *both* its object leg (`0x51770f`) and its
/// record leg (`0x517744`-`0x51775e`), so an out-of-range warrior's rage is not ten times
/// an in-range one's.
pub fn power_display_scale(ty: u8) -> u32 {
/// | caller | what it scales |
/// |---|---|
/// | `UnitMana 0x517670` (`0x51770f`), `UnitManaMax 0x5177e0` (`0x517864`) | the live descriptor's power slot |
/// | the same pair's party-record legs (`0x517748`, `0x5178a8`, `0x5178ee`) | [`crate::messages::PartyMemberStatsInfo::shown_power`] (decision 1640) |
/// | `SMSG_SPELLENERGIZELOG`'s handler `0x5e8a90` (`0x5e8af3`) | the amount, **once**, before both the chat line (`0x62ca00`) and the `COMBAT_TEXT_UPDATE` push (`0x494770`) |
/// | `SMSG_PERIODICAURALOG`'s handler `0x626dd0` (`0x627087`) | an energize tick's amount, the same way |
/// | the power leech/drain formatter `0x627930` | `drained = amount / div` and `gained = trunc(amount·multiplier) / div` |
/// | the spell tooltip's cost cell `0x52e610` (`0x52e8d2`, `0x52e8e7`) | the flat and per-level costs alike |
///
/// (wow-re `system/object-layer/scratch/combat-log-chat-law.md` §4.6 — *"Amounts are divided by
/// `0x6e7130(powerType)`"* — plus the disassembly at each address above.)
///
/// The **raw** accessors stay raw: the pet happiness bucket thresholds are on the raw scale, which
/// is why both forms have to exist (decision 1034).
///
/// The argument is a full 32-bit power tag because the wire's is (`SMSG_SPELLENERGIZELOG` ships a
/// `u32`) and because the reference's own compare is signed — `POWER_HEALTH (-2)` reaches
/// `0x6e7130` as a negative and takes the `1`. Arriving here as a large `u32` it takes the same
/// `1` off the same `_` arm.
pub fn power_display_scale(ty: u32) -> u32 {
match ty {
1 => 10, // rage
4 => 1000, // happiness
@@ -228,12 +243,12 @@ impl ObjectFields {
if self.unit_dynamic_flags() & UNIT_DYNFLAG_DEAD != 0 {
return (ty < 5).then_some(0);
}
Some(self.unit_power(ty)? / power_display_scale(ty))
Some(self.unit_power(ty)? / power_display_scale(u32::from(ty)))
}
/// Maximum power **as the UI reads it** — `UnitManaMax 0x5177e0`: the same
/// [`power_display_scale`] divide (`0x517864`), and deliberately **no** dead gate.
pub fn unit_shown_max_power(&self, ty: u8) -> Option<u32> {
Some(self.unit_max_power(ty)? / power_display_scale(ty))
Some(self.unit_max_power(ty)? / power_display_scale(u32::from(ty)))
}
/// `UNIT_FIELD_LEVEL` — the unit's level.
pub fn unit_level(&self) -> Option<u32> {
+3 -1
View File
@@ -18,6 +18,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::Model;
/// Action-kind bytes — bits 24–31 of the wire's packed slot word (VERIFIED vmangos `Player.h`
@@ -196,11 +197,12 @@ pub(super) fn truthy_nonzero(v: &Value) -> bool {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// HasAction(action) → 1/nil, the reference's predicate shape (`super::binding_abi::flag`).
g.set(
"HasAction",
lua.create_function(|lua, action: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.actions.contains_key(&action))
Ok(flag(model.actions.contains_key(&action)))
})?,
)?;
+1 -9
View File
@@ -41,6 +41,7 @@ use std::path::PathBuf;
use mlua::{Lua, MultiValue, Value};
use super::addon_gate::{can_load, GateRow, Verdict};
use super::binding_abi::flag;
use super::Model;
/// A reader for a chain-sourced addon's files, by chain-internal path — what the host seats
@@ -161,15 +162,6 @@ fn verdict(model: &Model, i: usize) -> Verdict {
can_load(&gate_rows(model), i, true, version_check(model))
}
/// `1`/`nil` — the client's boolean shape, which every addon tests with a bare `if`.
fn flag(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
fn lua_str(lua: &Lua, s: &str) -> mlua::Result<Value> {
Ok(Value::String(lua.create_string(s)?))
}
+1 -9
View File
@@ -42,6 +42,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{self, CursorPayload};
use super::Model;
@@ -300,15 +301,6 @@ impl super::UiScript {
}
}
/// A `1`/`nil` boolean the way the client pushes flags.
fn flag(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
/// Fetch a cloned row for a list type + 1-based index, or `None` (unknown type / out of range /
/// no session open).
fn row_at(model: &Model, kind: &str, index: usize) -> Option<AuctionItemRow> {
@@ -324,6 +324,31 @@ pub(crate) fn bool_or_default(v: Option<&Value>, default: bool) -> bool {
}
}
/// **The 1.12 predicate return** — `1` for true, `nil` for false, and never a Lua boolean.
///
/// The reference client has essentially no boolean-returning query. Across the 1503 rows
/// `reference/1.12-shapes.tsv` marks `kinds_conf = agree`, exactly two slots in the whole binding
/// surface are `boolean`: `IsPetAttackActive` (which really does call `lua_pushboolean 0x6f39f0`,
/// noted in [`super::pet`]) and Lua's own `rawequal`. Every other `Is*`, `Can*`, `Has*` and every
/// predicate slot of a multi-value answer is `(nil) | (number)`.
///
/// **The difference is invisible to `if x then` and decisive to everything else** — `x == 1`,
/// `x == nil`, `tostring(x)`, arithmetic, a table key, a value round-tripped through a saved
/// variable. That is why it survived so long: the transcribed FrameXML reads every one of these
/// with a plain `if`, so `true` and `1` were interchangeable for as long as *we* wrote the callers.
/// Real 1.12 addons are not our callers — `ColorPickerPlus.lua:121` writes
/// `if IsShiftKeyDown() == 1 then`, and reads a `true` as "not held" (decision 2118).
///
/// Enforced, not remembered: `ui_script::shape_gate::no_query_binding_answers_a_lua_boolean`
/// probes the whole registered query surface and fails on any Lua boolean.
pub(crate) fn flag(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
#[cfg(test)]
mod tests {
use super::*;
+128 -10
View File
@@ -132,6 +132,26 @@ impl super::UiScript {
pub fn take_channel_commands(&mut self) -> Vec<ChannelCommand> {
std::mem::take(&mut self.model_mut().channel_commands)
}
/// The guild-recruitment auto-join latch — `0` STANDARD, `1` AUTO (decision 2115).
pub fn guild_recruitment_mode(&self) -> u8 {
self.model_ref().guild_recruitment_mode
}
/// Seat the latch from the host (the per-character chat cache at login). A host write is not
/// a player gesture, so it does **not** arm [`Self::take_guild_recruitment_change`] — the same
/// split `set_cvar_from_host` keeps one store over, and what stops a login from composing the
/// player's file out of a value the login itself just read.
pub fn set_guild_recruitment_mode(&mut self, mode: u8) {
let mut model = self.model_mut();
model.guild_recruitment_mode = mode;
model.guild_recruitment_changed = false;
}
/// Has Lua moved the latch since the last drain? The chat cache's dirty signal.
pub fn take_guild_recruitment_change(&mut self) -> bool {
std::mem::take(&mut self.model_mut().guild_recruitment_changed)
}
}
fn push(lua: &Lua, cmd: ChannelCommand) {
@@ -164,18 +184,116 @@ fn name_at(model: &Model, n: usize) -> Option<&str> {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetChannelName(indexOrName) → slot, name, instanceID.
// ── The guild-recruitment auto-join pair (decision 2115) ──────────────────────────────────
//
// THE TRAP, and the whole risk in this verb: the first return is **always a NUMBER, never
// nil** — `0` when the channel is not joined. Verified from both sides. The reference's own
// callers compare it numerically and would raise on a nil: `ChatFrame.lua:2114`
// `if ( channelNum > 0 )` and `l.2232` `if ( channelNum <= 0 ) then return end`; so does the
// corpus, at `_LazyPig/LazyPig.lua:1996` `if id > 0 then`. Returning nil here would convert
// three working call sites into "attempt to compare nil with number".
// `GetGuildRecruitmentMode 0x4a0040` / `SetGuildRecruitmentMode 0x4a0060` — the store behind
// the reference's *Auto-join the Guild Recruitment Channel* option
// (`UIOptionsFrameCheckButtons["AUTO_JOIN_GUILD_CHANNEL"]`, index 51). The table entry is a
// bare `{ index = 51 }` with no cvar and no uvar, which reads as "unbound" and is not: the
// store is the three special-case arms `UIOptionsFrame_Load` (l.243-248), `_Save` (l.326-331)
// and `_SetDefaults` (l.649) keep for that key. Exactly the `SHOW_TUTORIALS` shape decision
// 2077 corrected, and this tree's own `OptionsFrame.xml` carried the wrong reading until 2115.
//
// NOT a shared helper with `JoinChannelByName`, whose first return is a DIFFERENT number — the
// `ChatChannels.dbc` ChannelID, not this local slot index. wow-re calls that pair out
// explicitly (`zone-chat-channel-autojoin.md` l.379) and it is an easy, silent mistake.
// Every byte fact below is wow-re `system/ui/scratch/guild-recruitment-mode.md`, a §5 round
// dispatched for this work.
//
// **The state is one int** — the reference's `[0x843608]`, written only by `0x49ea70`
// (`mov ds:0x843608,ecx`; five call sites, no address-takes) and read by the Lua getter and by
// the chat-cache writer. Ours is `Model::guild_recruitment_mode`, seated at login and rendered
// back out by `ui_chat::settings`; the whole round trip is that file's one
// `OPTION_GUILD_RECRUITMENT_CHANNEL STANDARD|AUTO` line, written at teardown (`0x499a80` at
// `0x499b0e`: `latch == 1` → `AUTO`, else `STANDARD`), never at set time.
//
// **It boots at 1 (AUTO)**, and that is a `.data` initialiser rather than a BSS zero — `raw
// 0x443608` is `01 00 00 00` — corroborated twice over: `_SetDefaults` calls
// `SetGuildRecruitmentMode(1)`, and all 33 `chat-cache.txt` files the reference client itself
// wrote in this repo's install say `AUTO`. See `Model::guild_recruitment_mode`.
g.set(
"GetGuildRecruitmentMode",
// `[0x4a0040, 0x4a0057)` is 23 bytes and one path: `fild dword [0x843608]` (signed i32),
// `fstp qword [esp]`, `lua_pushnumber`, `mov eax,1`, `ret`. No arguments, no gates — a
// NUMBER, always, never nil. `UIOptionsFrame_Load` compares it `== 1`, so a nil here would
// read as a silent "not auto".
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(f64::from(model.guild_recruitment_mode))
})?,
)?;
// ── NAMED, NOT BUILT: the `ecx == 1` cascade (decision 2115) ───────────────────────
// `0x49ea70` writes the latch and then tail-jumps into `0x49ea90` **only when the new
// mode is 1**, and `0x49ea90` is not bookkeeping — it acts, on the wire:
//
// * player IS guilded (`PLAYER_GUILDID`, player-block index 3) → `0x49eb17` leaves
// `GuildRecruitment - City`: `CMSG_LEAVE_CHANNEL` (`push 0x98`), the channel
// stripped from all ten chat windows, the ZONECHANNELS bit cleared;
// * player is NOT guilded and IS in a capital (`AreaTable Flags & 0x100`) →
// `0x49eb55` joins `GuildRecruitment`: `CMSG_JOIN_CHANNEL` (`push 0x97`);
// * otherwise — no resolvable zone row, or unguilded outside a capital — it merely
// arms the one-shot flag `[0xb6e5e4]`, which the next autojoin pass consumes.
//
// Both acting arms fire `UPDATE_CHAT_WINDOWS`, and `0x4a0060` fires it again, so an
// acting `Set(1)` fires it twice. `Set(0)` is the latch alone — `0x49ea70`'s `jne`
// stops at `0x49ea80`, no packet.
//
// **benilla does none of that**, and the reason is that it is a FEATURE this client
// has never had rather than a line of this verb: the zone-channel walk
// (`ui_chat::channels`) deliberately never joins GuildRecruitment (its row carries no
// INITIAL bit), so there is no guild-membership-driven join/leave to hang off. Building
// it means the guilded/unguilded gate, the capital-city gate and two channel commands,
// and it changes what the client puts on the wire when a player joins or leaves a
// guild — its own change, with its own record. The latch here is real, persists, and
// is what `GetGuildRecruitmentMode` answers; what it does not yet do is act.
//
// The same note's other half, also not built: `JoinChannelByName("GuildRecruitment")`
// (`0x49ed3d`) and `LeaveChannelByName` (`0x49ef8f`) each force the latch back to 0 —
// the join's reset is conditional on the caller's `flag` argument, which the Lua
// binding passes as 1 and `0x49ea90`'s own internal join passes as 0 (so the cascade
// does not undo the mode it is acting on).
g.set(
"SetGuildRecruitmentMode",
// `[0x4a0060, 0x4a00c4)`. One argument at stack index 1, and **two ways to raise** — this
// is a shape-A binding (wow-re `numeric-arg-coercion-law.md`), not one of the many that
// swallow a nil:
//
// * `lua_isnumber 0x6f34d0` — so a NUMERIC STRING passes (`"1"` works) — else
// `luaL_error("Usage: SetGuildRecruitmentMode(mode)")`, which longjmps.
// * then `__ftol 0x40a2b0`, **truncating toward zero**, so `1.7` is silently accepted as
// 1 and `-0.5` as 0.
// * then the range gate `0x4a0094 jl` / `0x4a009b jge 2` →
// `luaL_error("SetGuildRecruitmentMode: invalid mode")` for `-1`, `2`, `-1.7`.
//
// Success returns **0 values** (`xor eax,eax; ret`), not nil. Stock FrameXML can reach
// neither raise: `UIOptionsFrame_Save` passes `button:GetChecked()` and coerces its nil to
// 0 itself. An ADDON can, and getting the raise right is the difference between an addon
// seeing its own bug and seeing ours.
lua.create_function(|lua, mode: Option<Value>| {
let n = match mode.as_ref() {
Some(Value::Integer(i)) => *i as f64,
Some(Value::Number(n)) => *n,
// `lua_isnumber` is true for a string luaO_str2d fully consumes.
Some(Value::String(s)) => s
.to_str()
.ok()
.and_then(|s| s.trim().parse::<f64>().ok())
.ok_or_else(|| mlua::Error::runtime("Usage: SetGuildRecruitmentMode(mode)"))?,
_ => return Err(mlua::Error::runtime("Usage: SetGuildRecruitmentMode(mode)")),
};
let n = n.trunc();
if !(0.0..2.0).contains(&n) {
return Err(mlua::Error::runtime(
"SetGuildRecruitmentMode: invalid mode",
));
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mode = n as u8;
if model.guild_recruitment_mode != mode {
model.guild_recruitment_mode = mode;
model.guild_recruitment_changed = true;
}
Ok(())
})?,
)?;
g.set(
"GetChannelName",
lua.create_function(|lua, key: Value| {
+8 -5
View File
@@ -48,6 +48,7 @@
use mlua::{Lua, Value};
use super::binding_abi::flag;
use super::{binding_abi, Model};
/// The 1.12 weapon-subclass → `SkillLine.dbc` id table, transcribed from vmangos
@@ -952,16 +953,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// HasWandEquipped() → boolean (the ref's ranged block swaps "Shoot" in on it). No unit arg —
// HasWandEquipped() → 1/nil (the ref's ranged block swaps "Shoot" in on it). No unit arg —
// the live global is player-implicit.
g.set(
"HasWandEquipped",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model
.player_combat_stats
.as_ref()
.is_some_and(|s| s.has_wand))
Ok(flag(
model
.player_combat_stats
.as_ref()
.is_some_and(|s| s.has_wand),
))
})?,
)?;
+2 -1
View File
@@ -7,6 +7,7 @@
use mlua::Lua;
use crate::script::binding_abi::flag;
use crate::script::container::ContainerMove;
use crate::script::Model;
@@ -271,7 +272,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"IsInventoryItemLocked",
lua.create_function(|lua, id: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(is_inventory_item_locked(&model, id))
Ok(flag(is_inventory_item_locked(&model, id)))
})?,
)?;
+8 -2
View File
@@ -76,7 +76,13 @@ impl super::UiScript {
/// a live table refreshes defaults but never clobbers a value someone already set. A fresh
/// registration starts at the saved-base value when the config file carries one
/// ([`Self::set_cvar_saved_base`]), else at the default.
pub fn register_cvars<'a>(&mut self, vars: impl IntoIterator<Item = (&'a str, &'a str)>) {
///
/// Takes `&self` rather than `&mut self` because the table lives behind the VM's app-data
/// (interior mutability), and the interface loader has to be able to guarantee it from a
/// `&UiScript` — the stock `UIOptionsFrame.xml` reads CVars in its own `OnLoad`, so a VM that
/// loads the client's interface without the client's CVar table is not the client
/// (decision 2115).
pub fn register_cvars<'a>(&self, vars: impl IntoIterator<Item = (&'a str, &'a str)>) {
let mut model = self.model_mut();
for (name, default) in vars {
let key = name.to_ascii_lowercase();
@@ -476,7 +482,7 @@ mod tests {
use crate::script::UiScript;
fn script_with_volume() -> UiScript {
let mut s = UiScript::new().unwrap();
let s = UiScript::new().unwrap();
s.register_cvars([("MusicVolume", "0.4"), ("MasterVolume", "1.0")]);
s
}
+3 -2
View File
@@ -45,6 +45,7 @@
use mlua::{Lua, MultiValue, Table, Value};
use super::binding_abi::flag;
use super::Model;
/// `CLootButton`'s own Lua method table (`0x847ce4`) — see [`crate::widget::FrameKind::LootButton`].
@@ -330,14 +331,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// IsFishingLoot() → whether the open loot came from fishing (false when none is open).
// IsFishingLoot() → 1/nil, whether the open loot came from fishing (nil when none is open).
// `LootFrame_OnShow` keys the reel-in sound + the fishing portrait overlay on it
// (`LootFrame.lua:137-140`; decision 1086).
g.set(
"IsFishingLoot",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.loot.as_ref().is_some_and(|l| l.fishing))
Ok(flag(model.loot.as_ref().is_some_and(|l| l.fishing)))
})?,
)?;
+75
View File
@@ -662,3 +662,78 @@ mod tests {
);
}
}
#[cfg(test)]
mod error_quoting_tests {
use crate::script::UiScript;
/// **Every error message quotes a program element the way 5.0 does — `` `x' ``, not `'x'``
/// (decision 2122).**
///
/// 5.1 introduced `LUA_QL` and made it two apostrophes; the fork's `luaconf.h` puts 5.0's
/// backquote back. The five formats it feeds are all readable in `WoW.exe`'s own `.rdata`, and
/// the assertions below are those strings:
///
/// ```text
/// bad argument #%d to `%s' (%s) lauxlib.c luaL_argerror
/// calling `%s' on bad self (%s) lauxlib.c luaL_argerror's method arm
/// attempt to %s %s `%s' (a %s value) ldebug.c luaG_typeerror
/// %s:%d: %s near `%s' llex.c luaX_lexerror (the client's own 0x87217c)
/// in function `%s' ldblib.c the line debugstack renders
/// ```
///
/// **The traceback line is the one this does NOT reach**, and it is the one with teeth.
/// `debugstack` delegates to mlua's own traceback, which is built in mlua's C shim rather than
/// in this fork, so `LUA_QL` does not reach it: it still renders `in function 'X'`. That
/// matters because AceLibrary — shipped inside ~80 corpus addons — reads its own caller back
/// out of `debugstack()` with `string.find(debugstack(), "`argCheck'.-([`<].-['>])")`, whose
/// two patterns both require the backquote; the nil it gets is then handed to a `%s`, so an
/// argument-check *diagnostic* raises inside the error path. Closing that means rendering the
/// traceback ourselves in the reference's own shape, which is dispatched into wow-re
/// (`debugstack 0x703760`) rather than copied from stock 5.0's `ldblib.c`.
#[test]
fn errors_quote_program_elements_the_way_lua_5_0_does() {
let s = UiScript::new().unwrap();
let err = |lua: &str| {
s.eval::<String>(&format!(
"local ok, e = pcall(function() {lua} end) return tostring(e)"
))
.unwrap()
};
// luaG_typeerror.
let e = err("local t = nil return t.x");
assert!(
e.contains("attempt to index local `t' (a nil value)"),
"typeerror keeps 5.1's quoting: {e}"
);
// luaL_argerror, through a library function that raises one.
let e = err(r#"return string.rep(nil, 2)"#);
assert!(
e.contains("bad argument #1 to `rep'"),
"argerror keeps 5.1's quoting: {e}"
);
// luaX_lexerror — the client's own `0x87217c` format.
let e = s
.eval::<String>(r#"local f, e = loadstring("return 1 +") return tostring(e)"#)
.unwrap();
assert!(
e.contains("near `<eof>'"),
"the lexer keeps 5.1's quoting: {e}"
);
// And no message anywhere OPENS a quote with an apostrophe — 5.1's spelling puts one
// where 5.0 puts the backquote, so the tell is a `'` right after a space or a paren.
// (The closing quote is an apostrophe in both dialects, which is why this looks for the
// opening one and not for the character.)
for e in [
err("local t = nil return t.x"),
err("return nosuchfn()"),
err("return string.rep(nil, 2)"),
] {
assert!(
!e.contains(" '") && !e.contains("('"),
"a 5.1-quoted element survives in: {e}"
);
}
}
}
+1 -9
View File
@@ -30,6 +30,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{self, CursorPayload};
use super::Model;
@@ -284,15 +285,6 @@ impl super::UiScript {
}
}
/// A `1`/`nil` boolean the way the client pushes flags (`pushnumber(1)` / `pushnil`).
fn flag(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
/// Fetch a cloned inbox row for a 1-based index, or `None` (out of range / no mailbox open).
fn row_at(model: &Model, index: usize) -> Option<MailInboxRow> {
model
@@ -161,9 +161,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
with_mf(lua, &this, |mf| mf.fading_enabled = on)
})?,
)?;
// 1/nil, the reference's predicate shape — `binding-shapes.tsv` has this row as
// `(nil) | (number)`, like every other 1.12 predicate (decision 2118).
m.set(
"GetFading",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fading_enabled))?,
lua.create_function(|lua, this: Table| {
with_mf(lua, &this, |mf| {
crate::script::binding_abi::flag(mf.fading_enabled)
})
})?,
)?;
m.set(
"SetTimeVisible",
@@ -219,9 +219,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
with_smf(lua, &this, |smf| smf.fading_enabled = on)
})?,
)?;
// 1/nil, the reference's predicate shape — `binding-shapes.tsv` has this row as
// `(nil) | (number)`, like every other 1.12 predicate (decision 2118).
m.set(
"GetFading",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.fading_enabled))?,
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, |smf| {
crate::script::binding_abi::flag(smf.fading_enabled)
})
})?,
)?;
// The XML attr is `displayDuration`; the Lua accessors call the same field `TimeVisible`
// (msgframe-runtime.md).
+15
View File
@@ -604,6 +604,19 @@ pub(crate) struct Model {
pub(crate) zone_channel_catalog: Vec<super::channel::ZoneChannelRow>,
/// Channel verbs since the last [`super::UiScript::take_channel_commands`] drain.
pub(crate) channel_commands: Vec<super::channel::ChannelCommand>,
/// **The guild-recruitment auto-join latch** — the reference's int global `[0x843608]`, which
/// `GetGuildRecruitmentMode` returns and `SetGuildRecruitmentMode` writes (decision 2115).
///
/// `0` = STANDARD, `1` = AUTO, and those two words are literally what the per-character chat
/// cache stores it as (`OPTION_GUILD_RECRUITMENT_CHANNEL STANDARD|AUTO`; wow-re
/// `system/ui/scratch/chat-cache-grammar.md` — the reader maps `STANDARD` to 0 and anything
/// else, `AUTO` included, to 1). It boots at **1**: every one of the 33 `chat-cache.txt` files
/// the reference client itself wrote in this repo's install says `AUTO`, on characters that
/// never opened the option.
pub(crate) guild_recruitment_mode: u8,
/// Whether Lua has moved [`Self::guild_recruitment_mode`] since the last drain — the chat
/// cache's dirty signal, the peer of `chat_window_changes`.
pub(crate) guild_recruitment_changed: bool,
/// `DoEmote` calls since the last drain.
pub(crate) emote_requests: Vec<super::chat_misc::EmoteRequest>,
/// `RandomRoll` calls since the last drain.
@@ -1838,6 +1851,8 @@ impl Model {
known_languages: Vec::new(),
zone_channel_catalog: Vec::new(),
channel_commands: Vec::new(),
guild_recruitment_mode: 1,
guild_recruitment_changed: false,
emote_requests: Vec::new(),
roll_requests: Vec::new(),
uninvite_requests: Vec::new(),
+7 -3
View File
@@ -479,8 +479,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// The modifier-key mirror ([`UiScript::set_modifiers`], fed by the app's input pass before
// any mouse event each frame) — the reference handlers fork on these at click time
// (ContainerFrame.lua's shift-split / ctrl-dressup, ActionBarFrame.xml's shift-pickup,
// SpellBookFrame.lua's shift-pickup). Era booleans, not 1.12's 1/nil — the house API target
// (decision 0068); every transcribed `if IsShiftKeyDown()` reads both identically.
// SpellBookFrame.lua's shift-pickup).
//
// **1/nil, not a Lua boolean.** These three shipped Era booleans on 0068's reasoning that
// "every transcribed `if IsShiftKeyDown()` reads both identically" — true while we wrote every
// caller, and false the day real 1.12 addons load (1188/1751). `ColorPickerPlus.lua:121` is
// `if IsShiftKeyDown() == 1 then`, and a `true` reads there as "not held" (decision 2118).
for (name, pick) in [
("IsShiftKeyDown", 0usize),
("IsControlKeyDown", 1),
@@ -491,7 +495,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
let m = [model.modifiers.0, model.modifiers.1, model.modifiers.2];
Ok(m[pick])
Ok(crate::script::binding_abi::flag(m[pick]))
})?,
)?;
}
+3 -2
View File
@@ -21,6 +21,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::Model;
/// Which of the four questgiver sub-panels a [`QuestState`] is for (the app sets it from the wire
@@ -274,12 +275,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_count(lua, "GetRewardMoney", |q| i64::from(q.reward_money))?;
install_count(lua, "GetQuestMoneyToGet", |q| i64::from(q.required_money))?;
// IsQuestCompletable() → bool (progress panel gate for the Continue button).
// IsQuestCompletable() → 1/nil (progress panel gate for the Continue button).
g.set(
"IsQuestCompletable",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.quest.as_ref().is_some_and(|q| q.completable))
Ok(flag(model.quest.as_ref().is_some_and(|q| q.completable)))
})?,
)?;
+12 -8
View File
@@ -39,7 +39,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::number_arg;
use super::binding_abi::{flag, number_arg};
use super::quest::QuestItemView;
use super::Model;
@@ -537,16 +537,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// IsCurrentQuestFailed() → the selection's slot state is FAIL (ref appends " - (Failed)").
// IsCurrentQuestFailed() → 1/nil: the selection's slot state is FAIL (ref appends
// " - (Failed)").
g.set(
"IsCurrentQuestFailed",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let sel = model.quest_log_selection as usize;
Ok(sel
.checked_sub(1)
.and_then(|n| model.quest_log.entries.get(n))
.is_some_and(|e| e.complete < 0))
Ok(flag(
sel.checked_sub(1)
.and_then(|n| model.quest_log.entries.get(n))
.is_some_and(|e| e.complete < 0),
))
})?,
)?;
@@ -708,7 +710,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"IsUnitOnQuest",
lua.create_function(|_, (_q, _unit): (Value, Value)| Ok(false))?,
lua.create_function(|_, (_q, _unit): (Value, Value)| Ok(flag(false)))?,
)?;
// ── The quest watch (the on-screen tracker's state — ref QuestLogFrame.lua:469-505 shift-click
// toggle, :613-663 QuestWatch_Update). The set lives engine-side, keyed by the entries' stable
@@ -782,7 +784,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"IsQuestWatched",
lua.create_function(|lua, i: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(watch_id_at(&model, i).is_some_and(|id| model.quest_log_watched.contains(&id)))
Ok(flag(
watch_id_at(&model, i).is_some_and(|id| model.quest_log_watched.contains(&id)),
))
})?,
)?;
g.set(
+4 -10
View File
@@ -50,6 +50,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{queue_cursor_update, CursorPayload, CursorSpell};
use super::Model;
@@ -424,15 +425,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set("BOOKTYPE_SPELL", BOOKTYPE_SPELL)?;
g.set("BOOKTYPE_PET", BOOKTYPE_PET)?;
/// The 1/nil boolean every Era binding in this file answers with.
fn flag(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
// `UpdateSpells()` — twelve bytes in the reference (`[0x4b43e0,0x4b43ec)`), and its entire
// content is a bare `SignalEvent(SPELLS_CHANGED)`: event 260, **no arguments**, and NO state
// mutation whatsoever. Byte-carved by a wow-re cross-check (decision 1924, their
@@ -650,7 +642,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "IsSpellPassive")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(book_slot(&model, id, &book_type).is_some_and(|s| s.passive))
Ok(flag(
book_slot(&model, id, &book_type).is_some_and(|s| s.passive),
))
})?,
)?;
+3 -1
View File
@@ -290,7 +290,9 @@ function GetZonePVPInfo()
local t, f = __benilla_pvp_type, __benilla_pvp_faction
if t == "" then t = nil end
if f == "" then f = nil end
return t, f, __benilla_pvp_arena
-- isArena is 1/nil, never a Lua boolean: the reference's third slot is `(nil) | (number)`
-- like every other 1.12 predicate (decision 2118).
return t, f, __benilla_pvp_arena and 1 or nil
end
-- ── GetGameTime: the server's in-game clock (hour, minute) — the reference reads the
@@ -159,3 +159,87 @@ fn get_channel_list_is_a_flat_slot_name_vararg_in_join_order() {
0
);
}
/// **The guild-recruitment latch boots at AUTO and round-trips as a NUMBER** (decision 2115).
///
/// `GetGuildRecruitmentMode 0x4a0040` is 23 bytes and one path — `fild` the int global,
/// `lua_pushnumber`, `mov eax,1`, `ret` — so it has no nil leg at all, and
/// `UIOptionsFrame_Load`'s `== 1` would read a nil as a silent "not auto". The boot value is 1
/// from a `.data` initialiser (`raw 0x443608` = `01 00 00 00`), corroborated by
/// `UIOptionsFrame_SetDefaults`'s `SetGuildRecruitmentMode(1)` and by all 33 `chat-cache.txt`
/// files the reference client itself wrote in this repo's install.
#[test]
fn the_guild_recruitment_mode_boots_auto_and_answers_a_number() {
let s = script();
assert_eq!(
s.eval::<f64>("return GetGuildRecruitmentMode()").unwrap(),
1.0,
"the reference's own .data initialiser, not a BSS zero"
);
assert!(s
.eval::<bool>("return type(GetGuildRecruitmentMode()) == 'number'")
.unwrap());
s.run("SetGuildRecruitmentMode(0)").unwrap();
assert_eq!(
s.eval::<f64>("return GetGuildRecruitmentMode()").unwrap(),
0.0
);
}
/// **The setter is shape A — it RAISES rather than swallowing a bad argument** (decision 2115).
///
/// `0x4a0060` gates on `lua_isnumber 0x6f34d0` (so a numeric STRING passes) and otherwise
/// `luaL_error`s `Usage: SetGuildRecruitmentMode(mode)`; it then truncates toward zero through
/// `__ftol 0x40a2b0` and range-gates `0 <= mode < 2`, raising
/// `SetGuildRecruitmentMode: invalid mode` outside it. Most 1.12 numeric bindings swallow a nil
/// as 0.0 — this one does not, and a client that guessed the common shape would turn an addon's
/// own bug into silence (wow-re `numeric-arg-coercion-law.md`; the per-binding split is the whole
/// point of that note).
///
/// Success pushes **0 values**, not nil.
#[test]
fn the_guild_recruitment_setter_gates_its_argument_the_way_the_reference_does() {
let s = script();
// A numeric string is a number to `lua_isnumber`.
s.run(r#"SetGuildRecruitmentMode("0")"#).unwrap();
assert_eq!(
s.eval::<f64>("return GetGuildRecruitmentMode()").unwrap(),
0.0
);
// Truncation toward zero, not rounding: 1.7 is a legal 1.
s.run("SetGuildRecruitmentMode(1.7)").unwrap();
assert_eq!(
s.eval::<f64>("return GetGuildRecruitmentMode()").unwrap(),
1.0
);
for bad in ["", "nil", r#""AUTO""#, "{}", "true"] {
let e = s
.run(&format!("SetGuildRecruitmentMode({bad})"))
.expect_err(&format!("SetGuildRecruitmentMode({bad}) must raise"));
assert!(
e.to_string().contains("Usage: SetGuildRecruitmentMode"),
"{bad}: {e}"
);
}
for bad in ["-1", "2", "-1.7", "37"] {
let e = s
.run(&format!("SetGuildRecruitmentMode({bad})"))
.expect_err(&format!("SetGuildRecruitmentMode({bad}) must raise"));
assert!(e.to_string().contains("invalid mode"), "{bad}: {e}");
}
// …and none of the refused calls moved the latch.
assert_eq!(
s.eval::<f64>("return GetGuildRecruitmentMode()").unwrap(),
1.0
);
assert_eq!(
s.eval::<i64>("return select('#', SetGuildRecruitmentMode(0))")
.unwrap(),
0,
"0x4a0060 returns `xor eax,eax` — zero values, not a nil"
);
}
@@ -1658,7 +1658,7 @@ fn every_installed_region_method_lands_in_a_leaf() {
/// from one nil for a caller that counts.
#[test]
fn the_nameplate_verbs_ignore_their_arguments_and_return_nothing() {
let mut s = script();
let s = script();
s.register_cvars([
(crate::script::CVAR_NAMEPLATE_ENEMIES, "1"),
(crate::script::CVAR_NAMEPLATE_FRIENDS, "0"),
+19 -1
View File
@@ -106,13 +106,31 @@ impl super::UiScript {
/// binding reads it without a host round-trip. Reference FrameXML (CastingBarFrame & co.)
/// anchors durations on GetTime; the clock advancing in the same call that fires OnUpdate
/// keeps the two views of time consistent within a frame.
/// The current `GetTime()` value (`__benilla_now`, seconds) — the FrameXML session clock. The app
/// The current `GetTime()` value (`__benilla_now`, seconds) — the FrameXML clock. The app
/// reads it to stamp an absolute expiry into the clock a Lua countdown reads (the aura feed's
/// `expirationTime`, decision 0257); it is the same value `GetTime()` returns inside the VM.
pub fn now(&self) -> f64 {
self.lua.globals().get("__benilla_now").unwrap_or(0.0)
}
/// Start this VM's `GetTime()` clock at `secs` rather than at zero — how a host hands a
/// **freshly built** VM the clock its process is already on (decision 2116).
///
/// The reference's `GetTime` (`0x515ea0`) is `KERNEL32!GetTickCount` scaled by 0.001 (through
/// the thunk `0x42c010` → `0x42b790`; wow-re `system/core/ledger.tsv` boundary row and
/// `system/ui/ledger.tsv:3265`) — an **OS** clock that knows nothing about the Lua VM and never
/// restarts, which is exactly what lets stock `Cooldown.lua` gate on `start > 0`. Ours lives
/// inside the VM, so without this a rebuilt VM — every logout/login, every `ReloadUI` — would
/// restart it at zero and every host value already converted onto it would land in the past.
///
/// Set once, at construction: from then on only [`Self::tick`] moves the clock, so the
/// `(Instant, GetTime)` pair a host keeps beside it stays atomic by construction.
pub fn set_now(&mut self, secs: f64) {
if let Err(e) = self.lua.globals().set("__benilla_now", secs) {
self.push_error(e);
}
}
pub fn tick(&mut self, elapsed: f32) {
let clock = {
let g = self.lua.globals();
+1 -9
View File
@@ -27,6 +27,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{self, CursorPayload};
use super::Model;
@@ -153,15 +154,6 @@ impl super::UiScript {
}
}
/// A `1`/`nil` boolean the way the client pushes flags (`pushnumber(1)` / `pushnil`).
fn flag(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
/// Fetch a cloned slot for a 1-based index off one side, or `None` (out of range / empty / no trade
/// open). `pick` selects the side from the pushed [`TradeState`].
fn slot_at(
@@ -899,8 +899,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UnitSex(unit) → 2 male, 3 female (1 = neuter — no 1.12 unit feed produces it); nil when the
// unit is absent or the sex hasn't streamed (`0`), the API's "can't tell".
// UnitSex(unit) → 2 male, 3 female (1 = neuter — no 1.12 unit feed produces it).
//
// **The absent/unstreamed leg is the NUMBER 2, not nil** — `UnitSex 0x517f9f` pushes the
// constant double 2.0 (`push 0x40000000; push 0`), which wow-re's
// `unit-predicate-return-shape.md` §4 carves as the numeric-getter contrast to the predicate
// family's `1.0`/nil: the numeric getters in this same table answer a number on the
// unresolved leg, never nil. The shapes table agrees — this row is `(number)`, with no nil
// alternative anywhere (decision 2118).
g.set(
"UnitSex",
lua.create_function(|lua, token: Value| {
@@ -910,11 +916,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
r#"Usage: UnitSex("unit")"#,
)?);
let sex = with_unit(lua, &token, 0u8, |u| u.sex)?;
Ok(if sex == 0 {
Value::Nil
} else {
Value::Integer(i64::from(sex))
})
Ok(Value::Integer(i64::from(if sex == 0 { 2 } else { sex })))
})?,
)?;
+8 -7
View File
@@ -629,16 +629,18 @@ fn unit_race_class_sex_report_the_snapshot_or_the_absent_shape() {
("Warrior".into(), "WARRIOR".into())
);
assert_eq!(s.eval::<i64>(r#"return UnitSex("player")"#).unwrap(), 3);
// An absent token: nil, nil / nil — the live API's absent-unit shape.
// An absent token: the two string pairs go nil, nil — but **`UnitSex` answers the number 2**,
// not nil. It is a numeric getter, and `0x517f9f` pushes the constant double 2.0 on the
// unresolved leg exactly as `UnitLevel`/`UnitMana`/`GetMoney` push 0.0 on theirs (wow-re
// `unit-predicate-return-shape.md` §4; decision 2118). The shapes table has no nil alternative
// for this row at all.
assert!(s
.eval::<bool>(r#"local a, b = UnitRace("target") return a == nil and b == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"local a, b = UnitClass("target") return a == nil and b == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitSex("target") == nil"#)
.unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitSex("target")"#).unwrap(), 2);
// A snapshot whose race/class haven't resolved yet (feed pending): same nils.
s.set_unit(
"target",
@@ -650,9 +652,8 @@ fn unit_race_class_sex_report_the_snapshot_or_the_absent_shape() {
assert!(s
.eval::<bool>(r#"local a, b = UnitRace("target") return a == nil and b == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitSex("target") == nil"#)
.unwrap());
// Same for a seated body whose sex byte has not streamed: the number 2, never nil.
assert_eq!(s.eval::<i64>(r#"return UnitSex("target")"#).unwrap(), 2);
}
/// `UnitFactionGroup` returns the (english, localized) pair the PvP-icon law reads, and `nil, nil`
+4 -3
View File
@@ -41,17 +41,18 @@ downgrade, and it is the entire reason this is a one-hunk fork rather than a ven
## The delta, and the command that proves it
**Current state: seven hunks, in three files.** `src/lib.rs` additionally differs by having Lua
**Current state: eight hunks, in three files.** `src/lib.rs` additionally differs by having Lua
5.2/5.3/5.4/5.5 stripped from the `Version` enum (with their source trees deleted) — benilla builds
5.1 and only 5.1, and a fork that still *offered* the other four would answer a request for one with
a missing directory at build time instead of a compile error.
**Four hunks restore what 5.1 changed or removed:**
**Five hunks restore what 5.1 changed or removed:**
| file | hunk | why | record |
|---|---|---|---|
| `lvm.c` | 5.0's `OP_TFORPREP` table→`next` substitution, folded into the top of `OP_TFORLOOP` | `for k, v in someTable do` raised "attempt to call a table value"; it was the first session-start error for **60** corpus addons | 1215 |
| `luaconf.h` | `LUA_COMPAT_LSTR` `1` → `2` | 5.1 kept 5.0's nesting machinery and put an advisory error in front of it; two corpus addons died on "nesting of `[[...]]` is deprecated" | — |
| `luaconf.h` | `LUA_QL(x)` back to 5.0's `` `x' `` from 5.1's `'x'` | every Lua error message quotes a program element, and `WoW.exe`'s own `.rdata` carries all five formats in the 5.0 spelling (`` bad argument #%d to `%s' (%s) ``, `` attempt to %s %s `%s' (a %s value) ``, `` %s:%d: %s near `%s' `` at the client's `0x87217c`, …). ~80 corpus addons ship AceLibrary, whose `argCheck` parses its own caller back out of `debugstack()` with a pattern that requires the backquote | 2122 |
| `lparser.c` | 5.0's compat-semicolon skip restored at the top of `constructor()`'s field loop — 5.0's own line, verbatim, comment included | one extra `;` after a field separator inside a table constructor (`Back_Title = AL["Factions"];;`, ×20 in AtlasLoot's `ButtonRegistry.lua`) parses on the client and died here with "unexpected symbol near `;`"; statement-level `;;` stays rejected by both dialects | 1315 |
| `lparser.c` | `recfield`'s `cc->nh++` moved back INSIDE its `TK_NAME` arm — 5.0's own placement | a `[expr] = value` constructor field must credit **neither** `OP_NEWTABLE` size hint, so the table is born on the dummy node, the first store rehashes, and the dense integer keys land in an ARRAY part that `next` walks ascending. 5.1 pre-sizes a node vector instead and leaves them in the hash, where `next` is slot order — so every list in every saved-variables file came back scrambled (Bagnon's keyring drew first) | 2111 |
@@ -85,7 +86,7 @@ diff -r third_party/lua-src/lua-5.1.5 \
~/.cargo/registry/src/*/lua-src-550.0.0/lua-5.1.5
```
That must print **exactly** the seven hunks in the tables above and nothing else. If it ever prints
That must print **exactly** the eight hunks in the tables above and nothing else. If it ever prints
more, something crept in.
## Status of the payload
+18 -1
View File
@@ -200,7 +200,24 @@
@@ LUA_QL describes how error messages quote program elements.
** CHANGE it if you want a different appearance.
*/
#define LUA_QL(x) "'" x "'"
/*
** BENILLA (decision 2122): 1.12's Lua is 5.0, which quotes every program element in an error
** message with a BACKQUOTE and an apostrophe. 5.1 introduced this macro and made it two
** apostrophes. Read out of WoW.exe's own .rdata, which carries all five of the formats this macro
** feeds, in the 5.0 spelling:
**
** bad argument #%d to `%s' (%s) lauxlib.c luaL_argerror
** calling `%s' on bad self (%s) lauxlib.c luaL_argerror's method arm
** attempt to %s %s `%s' (a %s value) ldebug.c luaG_typeerror
** %s:%d: %s near `%s' llex.c luaX_lexerror (the client's own 0x87217c)
** in function `%s' ldblib.c the traceback line debugstack renders
**
** The last one is not cosmetic: 80+ corpus addons ship AceLibrary, whose argCheck reads its own
** caller back out of `debugstack()` with the pattern "`argCheck'.-([`<].-['>])", a pattern
** cannot match 5.1's quoting, so the nil it gets is then passed to a %s and raises inside the
** error path.
*/
#define LUA_QL(x) "`" x "'"
#define LUA_QS LUA_QL("%s")