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:
@@ -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>
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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)?))
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 })))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Vendored
+4
-3
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user