the flight map goes stock, and vendors refuse in 1.12's own words

- the pet stable and the flight map are the reference's own files: `TaxiRouteFrame` maps to a plain Frame (factory `0x495ba0` allocates the base size, the ctor adds no field, no new virtual, no method table — and `GetObjectType` is not overridden, so its own kind would have been the divergence), and the swap found three silent behaviours — the close button did nothing (an unknown `inherits=` is a WARNING, so it loaded as a bare button), the "no paths" line drew empty, and the title is READ off `UnitName("npc")`, not received; the WMO skybox rides the interior crossfade — one weight (`[0xce9bdc]`, the same number the MFOG lerp rides), a 4 s alpha fade, so a skybox the flood publishes through a doorway draws at weight 0 instead of popping in whole
- a widget predicate answers the NUMBER 1 or nil, never a boolean: eleven of them (`IsShown`, `IsVisible`, `IsMovable`, `GetChecked`…) move to the 1.12 shape — the reference proves it in its own source (`tostring(this:GetChecked())` saved and read back `== "1"`; `tostring(true)` would never match and buff timers would never appear), 21 corpus sites compare directly (`IsVisible() == nil`, `GetChecked() == 1`), and one of our own tests had the guild rank editor sending `true` where a real client sends 1
- the vendor's refusals are the reference's table: `SMSG_BUY_FAILED`/`SMSG_SELL_ITEM` route through `0x5dcd60`/`0x5dd1f0`'s switches, six lines were wrong (code 3 printed "That vendor can't be found." — vmangos's identifier — where 1.12 prints "That merchant doesn't like you."), silence is now expressible, and keying them gives the vendor a voice (`ERR_NOT_ENOUGH_MONEY` carries error-speech line 0x28); the bank's three ids were right and its "purchase failed (n)" an invention; the logout refusal is one key, `ERR_LOGOUT_FAILED`, for every non-zero reason; the two mail codes we said had no string have one (`ERR_PLAYER_WRONG_FACTION`, `ERR_RESTRICTED_ACCOUNT`) and a sent mail says `ERR_MAIL_SENT` in yellow; a test runs the chain's own `GlobalStrings.lua` and requires non-empty text for all 193 `ERR_*` the workspace names
- a clicked quest timer no longer raises — stock `QuestTimerFrame.lua` calls `QuestLog_SetSelection`/`QuestLog_Update`, which existed nowhere in this tree; our quest log owns the reference's two names now, which also shrinks the delta when that window itself migrates

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:05:15 +07:00
co-authored by Claude Fable 5.1
parent b14dd42cf1
commit b8e0025bc9
38 changed files with 905 additions and 1564 deletions
-715
View File
@@ -1,715 +0,0 @@
<Ui>
<!--
benilla's stable-master window (decision 1676) — the hunter's three-slot pet stable, dressed
from the real 1.12 `PetStable.xml`/`PetStable.lua`. Every rule below is cited back to that
extraction: `xml l.N` is `ref-PetStable.xml`, `lua l.N` is `ref-PetStable.lua`, and
`GlobalStrings l.N` is the shipped `GlobalStrings.lua`.
**The Lua lives here, inline.** The reference splits it (`<Script file="PetStable.lua"/>`,
xml l.3); benilla ships one file per window, so the transcription sits in the CDATA block
below in the reference's own order (OnLoad, OnEvent, Update, NoPetsAllowed).
**An NPC session, reached through gossip**, and it behaves like every other one here
(TrainerFrame.xml/MerchantFrame.xml's shape): a left-slot UIPanel (registered in
UiPanels.xml, the reference's own `UIParent.lua:39` row), a "npc" portrait bake, and an
`OnHide` that ends the session. The one thing that differs from its neighbours is that the
close sends NO PACKET: `ClosePetStables()` is purely client-side (there is no close opcode
in 5875 — `benilla-ui/src/script/stable.rs`'s own note), so the hide just clears the app's
session.
**Three slots, indices 0..2.** `NUM_PET_STABLE_SLOTS = 2` (lua l.1) is the count of *stable*
slots a hunter can buy; slot 0 is the pet at their side, which is why every loop below runs
`1..NUM_PET_STABLE_SLOTS` for the stabled pair and treats 0 separately.
Named deferrals / deviations, each re-cited at its own site below:
- **The model pane is the reference's own `<PlayerModel name="PetStableModel">`**
(xml l.182-280), and so are the four scripts on it: `Model_OnLoad`, `Model_OnUpdate`
and the two `Model_Rotate*` the buttons call (UIParent.xml). It was a plain `<Frame>`
sampling a booth bake through `BenillaSetBoothTexture`, with the rotate quartet borrowed
from our CharacterFrame.xml — both went when the character window migrated (decision
1751): the widget draws its own booth now, and the yaw the app mirrors is the pane's
own `SetRotation` state.
One thing the reference's pane does that ours still does not: `DISPLAY_SIZE_CHANGED` →
`this:RefreshUnit()` (xml l.196-200) is a no-op here, because the pane holds the unit
TOKEN and the app re-resolves it every frame — there is no cached appearance to
invalidate (`script/modelframe.rs`'s `RefreshUnit`).
- The pane's booth is the `"stable"` slot in `crates/benilla-app/src/portrait`, joined to
the pane by name in that module's `MODEL_PANE_BOOTHS`. The app resolves
`GetSelectedStablePet()`'s slot into either the live pet's entity or — for a stabled or
dismissed pet, which has no world object at all — the creature query's
`CreatureDisplayInfo` id, and the booth mirrors whichever it got.
- **`GetPetIcon()` does not exist in this client** (reference/1.12-globals.tsv has it as
an engine global; benilla has never needed it). `PetStable_Update` calls it at lua l.51
and l.161-162 and it is transcribed verbatim rather than guarded: the window's current
pet slot cannot paint without it, and a guard here would hide a missing binding instead
of reporting it.
- `parent="UIParent"` (xml l.72) is ours too, and it attaches for real: the loader does
read a cross-file `parent=`, resolving the name by global lookup at load (decision
1734), and the frame below declares it. The frame's own `<Anchors>` are kept verbatim even
though `ShowUIPanel` re-`SetPoint`s it into the left slot — they name the same
TOPLEFT (0,-104) the slot manager writes, so the two agree (ItemTextFrame.xml keeps its
reference anchor for the same reason).
- `PetStableMoneyFrame`'s instance `<OnLoad>SmallMoneyFrame_OnLoad()</OnLoad>` (xml
l.360-362) is dropped, and the cost frame's OnLoad keeps BOTH calls. benilla's
`SmallMoneyFrameTemplate` already wires `SmallMoneyFrame_OnLoad` as its own OnLoad
(MoneyFrame.xml) where the reference's template does not, and an instance `<Scripts>`
block SetScripts over the inherited handler rather than adding to it — so the purse
wants no OnLoad at all, and any instance that needs `MoneyFrame_SetType` must re-call
the loader beside it. That is the idiom AuctionFrame.xml and MailFrame.xml already use.
- Two housekeeping fixes inside `PetStable_Update`, both behaviour-identical: `background`
is a LOCAL here (lua l.76 leaks it as a global), and the reference's `numSlots`/`numPets`
locals (lua l.69-70) are dropped because nothing reads them — the loop asks
`GetNumStableSlots()` directly, exactly as the reference's own l.79 does.
- The GlobalStrings the window reads are declared below with the `X = X or "..."` idiom
this codebase uses: the real localized file loads off the patch chain before our own
files (`ui_script/lifecycle.rs`'s `load_global_strings`) and always wins; the fallbacks
are what make the strings resolve in the test harnesses, which load no chain. They
matter at LOAD, not just at runtime: a `text=` attribute is a global-string lookup
performed while the frames below are being built.
-->
<Script><![CDATA[
-- ── GlobalStrings this window uses (values + line cites from the shipped file) ───────────
STABLES = STABLES or "Stables" -- :4189
CURRENT_PET = CURRENT_PET or "Current Pet:" -- :881
STABLED_PETS = STABLED_PETS or "Stabled Pets:" -- :4188
EMPTY_STABLE_SLOT = EMPTY_STABLE_SLOT or "Empty Stable Slot" -- :1434
STABLE_SLOT_TEXT = STABLE_SLOT_TEXT
or "Do you wish to purchase another stable slot?" -- :4190
COSTS_LABEL = COSTS_LABEL or "Cost:" -- :870
PURCHASE = PURCHASE or "Purchase" -- :3128
PET_DIET_TEMPLATE = PET_DIET_TEMPLATE or "Diet: %s" -- :3037
UNIT_LEVEL_TEMPLATE = UNIT_LEVEL_TEMPLATE or "Level %d" -- :4436
-- lua l.1. The count of STABLE slots (the two a hunter buys), not of window slots: the
-- buttons run 0..2 with 0 being the pet at the player's side, which is why the loops below
-- iterate 1..NUM_PET_STABLE_SLOTS and handle 0 on its own.
NUM_PET_STABLE_SLOTS = 2
-- ── The window ──────────────────────────────────────────────────────────────────────────
-- lua l.3-9, verbatim. UNIT_PET is here because a pet summoned or dismissed while the
-- window is open changes slot 0 without the server sending a stable list.
function PetStable_OnLoad()
this:RegisterEvent("PET_STABLE_SHOW")
this:RegisterEvent("PET_STABLE_UPDATE")
this:RegisterEvent("PET_STABLE_UPDATE_PAPERDOLL")
this:RegisterEvent("PET_STABLE_CLOSED")
this:RegisterEvent("UNIT_PET")
end
-- lua l.11-32, all four arms. NOTE the SHOW arm's shape: `ShowUIPanel` can REFUSE (dead
-- player, a native-center frame holding the screen — UiPanels.xml's `AdmitPanel`), and the
-- reference reads that refusal back off `IsVisible()` and ends the session rather than
-- leaving one open behind a window nobody can see. Ours can refuse for the same reasons and
-- has the same session to end (`crate::ui_stable`'s), so the check is load-bearing here
-- too, not defensive.
function PetStable_OnEvent()
if event == "PET_STABLE_SHOW" then
ShowUIPanel(this)
if not this:IsVisible() then
ClosePetStables()
return
end
PetStable_Update()
elseif event == "PET_STABLE_UPDATE" or (event == "UNIT_PET" and arg1 == "player") then
PetStable_Update()
elseif event == "PET_STABLE_UPDATE_PAPERDOLL" then
-- So warlock pets don't show (lua l.23-27, the reference's own comment). A warlock
-- standing at a stable master has a pet but no pet UI, and the window must not
-- offer to stable it.
if UnitExists("pet") and not HasPetUI() then
PetStable_NoPetsAllowed()
return
end
SetPetStablePaperdoll(PetStableModel)
elseif event == "PET_STABLE_CLOSED" then
HideUIPanel(this)
end
end
-- lua l.34-209 — the whole repaint, in the reference's own order and with its own
-- structure. Read it as five passes: the portrait, the warlock gate, the auto-selection,
-- the two stabled slots, the current-pet slot, and the purchase row.
function PetStable_Update()
-- Set stablemaster portrait (lua l.35-36): the "npc" unit token, the same live model
-- bake every other NPC-session window binds.
SetPortraitTexture(PetStableFramePortrait, "npc")
-- So warlock pets don't show (lua l.38-46, the reference's own comment). NOTE this arm
-- reads `isHunterPet`, the SECOND return, where the paperdoll arm above reads the
-- first: a warlock's minion answers hasPetUI true and isHunterPet false, and it is the
-- second that decides whether this window has anything to do.
local hasPetUI, isHunterPet = HasPetUI()
if UnitExists("pet") and not isHunterPet then
PetStable_NoPetsAllowed()
PetStableCurrentPet:Disable()
return
else
PetStableCurrentPet:Enable()
end
-- If no selected pet try to set one (lua l.48-63). The -1 sentinel IS the API
-- (`GetSelectedStablePet` returns it, and `stable.rs` defaults its selection to it for
-- exactly this branch): a freshly opened window picks the current pet if there is one,
-- otherwise the first occupied stable slot, so the pane is never blank when it has
-- something to show.
local selectedPet = GetSelectedStablePet()
if selectedPet == -1 then
if GetPetIcon() then
selectedPet = 0
ClickStablePet(0)
else
for i = 0, NUM_PET_STABLE_SLOTS do
if GetStablePetInfo(i) then
selectedPet = i
ClickStablePet(i)
break
end
end
end
end
-- Set slot cost (lua l.65-66).
MoneyFrame_Update("PetStableCostMoneyFrame", GetNextStableSlotCost())
-- Set slot statuseses (lua l.68-119, the reference's own spelling of the comment).
-- An UNBOUGHT slot is drawn differently from an EMPTY one: bought-and-empty keeps its
-- white empty-slot art and stays clickable, unbought goes red and disabled. That is the
-- whole visual difference between "you may stable a pet here" and "buy this first".
local button
local background
local icon, name, level, family, loyalty
for i = 1, NUM_PET_STABLE_SLOTS do
button = getglobal("PetStableStabledPet" .. i)
background = getglobal("PetStableStabledPet" .. i .. "Background")
icon, name, level, family, loyalty = GetStablePetInfo(i)
SetItemButtonTexture(button, icon)
if i <= GetNumStableSlots() then
background:SetVertexColor(1.0, 1.0, 1.0)
button:Enable()
if icon then
button.tooltip = name
button.tooltipSubtext = format(TEXT(UNIT_LEVEL_TEMPLATE), level) .. " " .. family
else
button.tooltip = EMPTY_STABLE_SLOT
button.tooltipSubtext = ""
end
if i == selectedPet then
if icon then
button:SetChecked(1)
PetStableLevelText:SetText(
name .. " " .. format(TEXT(UNIT_LEVEL_TEMPLATE), level) .. " " .. family)
PetStableLoyaltyText:SetText(loyalty)
SetPetStablePaperdoll(PetStableModel)
PetStablePetInfo.tooltip =
format(PET_DIET_TEMPLATE, BuildListString(GetStablePetFoodTypes(i)))
if not PetStableModel:IsShown() then
PetStableModel:Show()
end
else
button:SetChecked(nil)
PetStableLevelText:SetText("")
PetStableLoyaltyText:SetText("")
PetStableModel:Hide()
end
else
button:SetChecked(nil)
end
-- lua l.109-114: if this button's tooltip is the one currently up, rebuild it
-- in place. A repaint that changed a slot under a resting cursor would
-- otherwise leave the previous pet's name on screen.
if GameTooltip:IsOwned(button) then
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
GameTooltip:SetText(button.tooltip)
GameTooltip:AddLine(button.tooltipSubtext, "", 1.0, 1.0, 1.0)
GameTooltip:Show()
end
else
background:SetVertexColor(1.0, 0.1, 0.1)
button:Disable()
end
end
-- Current pet slot (lua l.121-159). Three cases, and the middle one is the interesting
-- one: a hunter whose pet is dismissed still has a slot-0 row from the server (read off
-- the character-pet cache), so the reference falls back to `GetStablePetInfo(0)` rather
-- than showing an empty current-pet slot for a pet that merely is not out.
if selectedPet == 0 then
if UnitExists("pet") then
PetStableCurrentPet:SetChecked(1)
local family = UnitCreatureFamily("pet")
if not family then
family = ""
end
PetStableLevelText:SetText(
UnitName("pet") .. " " .. format(TEXT(UNIT_LEVEL_TEMPLATE), UnitLevel("pet"))
.. " " .. family)
PetStableLoyaltyText:SetText(GetPetLoyalty())
SetPetStablePaperdoll(PetStableModel)
if not PetStableModel:IsShown() then
PetStableModel:Show()
end
if GetPetFoodTypes() then
PetStablePetInfo.tooltip =
format(PET_DIET_TEMPLATE, BuildListString(GetPetFoodTypes()))
end
elseif GetStablePetInfo(0) then
-- If pet doesn't exist it might be dismissed, so check stable slot 0 for
-- current pet info (lua l.139, the reference's own comment).
PetStableCurrentPet:SetChecked(1)
icon, name, level, family, loyalty = GetStablePetInfo(0)
PetStableLevelText:SetText(
name .. " " .. format(TEXT(UNIT_LEVEL_TEMPLATE), level) .. " " .. family)
PetStableLoyaltyText:SetText(loyalty)
SetPetStablePaperdoll(PetStableModel)
if not PetStableModel:IsShown() then
PetStableModel:Show()
end
if GetStablePetFoodTypes(0) then
PetStablePetInfo.tooltip =
format(PET_DIET_TEMPLATE, BuildListString(GetStablePetFoodTypes(0)))
end
else
PetStableCurrentPet:SetChecked(nil)
PetStableLevelText:SetText("")
PetStableLoyaltyText:SetText("")
PetStableModel:Hide()
end
else
PetStableCurrentPet:SetChecked(nil)
end
-- Set tooltip and icon info (lua l.160-181). The same live/dismissed/absent ladder as
-- the block above, for the BUTTON's own icon and hover rather than the pane.
if GetPetIcon() then
SetItemButtonTexture(PetStableCurrentPet, GetPetIcon())
PetStableCurrentPet.tooltip = UnitName("pet")
PetStableCurrentPet.tooltipSubtext =
format(TEXT(UNIT_LEVEL_TEMPLATE), UnitLevel("pet")) .. " " .. UnitCreatureFamily("pet")
elseif GetStablePetInfo(0) then
icon, name, level, family, loyalty = GetStablePetInfo(0)
SetItemButtonTexture(PetStableCurrentPet, icon)
PetStableCurrentPet.tooltip = name
PetStableCurrentPet.tooltipSubtext =
format(TEXT(UNIT_LEVEL_TEMPLATE), level) .. " " .. family
else
SetItemButtonTexture(PetStableCurrentPet, "")
PetStableCurrentPet.tooltip = EMPTY_STABLE_SLOT
PetStableCurrentPet.tooltipSubtext = ""
PetStableCurrentPet:SetChecked(nil)
end
if GameTooltip:IsOwned(PetStableCurrentPet) then
GameTooltip:SetOwner(PetStableCurrentPet, "ANCHOR_RIGHT")
GameTooltip:SetText(PetStableCurrentPet.tooltip)
GameTooltip:AddLine(PetStableCurrentPet.tooltipSubtext, "", 1.0, 1.0, 1.0)
GameTooltip:Show()
end
-- If no selected pet clear everything out (lua l.183-189). Reachable even after the
-- auto-selection above: a hunter with no pet and an empty stable has nothing to select.
if selectedPet == -1 then
-- no pet
PetStableModel:Hide()
PetStableLevelText:SetText("")
PetStableLoyaltyText:SetText("")
end
-- Enable, disable, or hide purchase button (lua l.191-208). Three states, and the first
-- is a HIDE of the whole row: with both slots bought there is nothing left to sell, so
-- the button, the "Cost:" label, its coins and the slot-purchase line all go. Below
-- that the button only enables when the player can afford it, and the coins turn red
-- when they cannot. The leading unconditional Show() is the reference's own and it is
-- what brings the button back after a NoPetsAllowed() pass hid it.
PetStablePurchaseButton:Show()
if GetNumStableSlots() == NUM_PET_STABLE_SLOTS then
PetStablePurchaseButton:Hide()
PetStableCostLabel:Hide()
PetStableCostMoneyFrame:Hide()
PetStableSlotText:Hide()
elseif GetMoney() >= GetNextStableSlotCost() then
PetStablePurchaseButton:Enable()
PetStableCostLabel:Show()
PetStableCostMoneyFrame:Show()
SetMoneyFrameColor("PetStableCostMoneyFrame",
HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
else
PetStablePurchaseButton:Disable()
PetStableCostLabel:Show()
PetStableCostMoneyFrame:Show()
SetMoneyFrameColor("PetStableCostMoneyFrame",
RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b)
end
end
-- lua l.211-229 — the warlock face of the window, called from BOTH the paperdoll event arm
-- and the top of Update. It is not "clear the window": it is the window with nothing on
-- offer — every slot unchecked and tooltipped as empty, the pane hidden, and the whole
-- purchase row gone, because a warlock cannot buy a stable slot either.
function PetStable_NoPetsAllowed()
local button
for i = 1, NUM_PET_STABLE_SLOTS do
button = getglobal("PetStableStabledPet" .. i)
button.tooltip = EMPTY_STABLE_SLOT
button:SetChecked(nil)
end
PetStableCurrentPet:SetChecked(nil)
PetStableLevelText:SetText("")
PetStableLoyaltyText:SetText("")
PetStableModel:Hide()
SetItemButtonTexture(PetStableCurrentPet, "")
PetStableCurrentPet.tooltip = EMPTY_STABLE_SLOT
PetStableCurrentPet:SetChecked(nil)
PetStablePurchaseButton:Hide()
PetStableCostLabel:Hide()
PetStableCostMoneyFrame:Hide()
PetStableSlotText:Hide()
end
]]></Script>
<!--
One slot button (xml l.4-71): a 37x37 CheckButton wearing the standard quickslot ring, with
the empty-slot art behind it and the pet's icon in front. `$parentIconTexture` is the name
`SetItemButtonTexture` addresses (ItemButtonTemplate.xml's naming contract), and
`$parentBackground` is the one Update tints red for an unbought slot.
**`ClickStablePet` is wired to BOTH `OnClick` and `OnReceiveDrag`, and the frame repaints
only when it returns true.** That is the reference's own arrangement and it is transcribed
exactly, deliberately: the binding carries two jobs behind one name (commit a drag if one is
in flight, otherwise select) and its drag half is still being reverse-engineered
(`benilla-ui/src/script/stable.rs`'s `drag_verb`, INFERRED). Baking an assumption about
which it does into this XML would put the wrong answer in two places instead of one, so the
XML asks the same question the reference asks and repaints on the same answer.
`RegisterForDrag("LeftButton")` is what arms `OnDragStart` on a press-and-move; the pickup
it starts is FRAME-LOCAL, not the global cursor (there is no stable-pet cursor mode in
1.12 — stable.rs's module doc), so nothing else on screen can be holding a stable pet.
-->
<CheckButton name="PetStableSlotTemplate" virtual="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Layers>
<Layer level="BORDER">
<Texture name="$parentIconTexture"/>
</Layer>
<Layer level="BACKGROUND">
<Texture name="$parentBackground" file="Interface\Buttons\UI-EmptySlot">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="-13" y="13"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnLoad>this:RegisterForDrag("LeftButton")</OnLoad>
<OnEnter>
-- xml l.31-38. Guarded on `this.tooltip` because Update has not necessarily run
-- yet: a hover before the first repaint has nothing to say and says nothing.
if this.tooltip then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(this.tooltip)
GameTooltip:AddLine(this.tooltipSubtext, "", 1.0, 1.0, 1.0)
GameTooltip:Show()
end
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
<OnClick>
if ClickStablePet(this:GetID()) then
PetStable_Update()
end
</OnClick>
<OnDragStart>PickupStablePet(this:GetID())</OnDragStart>
<OnReceiveDrag>
if ClickStablePet(this:GetID()) then
PetStable_Update()
end
</OnReceiveDrag>
</Scripts>
<NormalTexture name="$parentNormalTexture" file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Quickslot-Depress"/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
</CheckButton>
<Frame name="PetStableFrame" parent="UIParent" toplevel="true" movable="true" enableMouse="true" hidden="true">
<!-- xml l.72-85: the standard 384x512 panel window. The HitRectInsets pull the mouse rect
in from the art's transparent right edge and bottom skirt. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-104"/></Offset></Anchor>
</Anchors>
<HitRectInsets><AbsInset left="0" right="34" top="0" bottom="75"/></HitRectInsets>
<Layers>
<!-- The stable master's portrait, 60x60 TOPLEFT (7,-6) (xml l.88-99). Bound in Update
via SetPortraitTexture(region, "npc") — the live model bake of whoever we are
talking to, the same binding every NPC-session window here uses. -->
<Layer level="BACKGROUND">
<Texture name="PetStableFramePortrait">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="BORDER">
<!-- The four quadrant slabs that ARE the window art (xml l.102-133): 256-wide left
halves, 128-wide right halves, each 256 tall, pinned to their own corner. -->
<Texture file="Interface\PetStableFrame\UI-PetStable-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\PetStableFrame\UI-PetStable-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="PetStableFrameBottomLeft" file="Interface\PetStableFrame\UI-PetStable-BottomLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture name="PetStableFrameBottomRight" file="Interface\PetStableFrame\UI-PetStable-BottomRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
<!-- Title (xml l.134-142). -->
<FontString name="PetStableTitleLabel" inherits="GameFontHighlight" text="STABLES">
<Anchors>
<Anchor point="TOP"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- The selected pet's name/level/family line and its loyalty line (xml l.143-160).
The reference ships them carrying PLACEHOLDER text ("Bobby The Fleshripper
(Level 99 Raptor)", "Loyalty: Mans Best Friend") — an authoring artifact that
is on screen for the instant before the first Update, and one this engine would
render literally (a `text=` that misses the global string table falls back to
the literal). Dropped: the strings are Update's to write. -->
<FontString name="PetStableLevelText" inherits="GameFontNormal">
<Anchors>
<Anchor point="TOP" relativeTo="PetStableTitleLabel" relativePoint="BOTTOM">
<Offset><AbsDimension x="0" y="-8"/></Offset>
</Anchor>
</Anchors>
</FontString>
<FontString name="PetStableLoyaltyText" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="TOP" relativeTo="PetStableLevelText" relativePoint="BOTTOM">
<Offset><AbsDimension x="0" y="-1"/></Offset>
</Anchor>
</Anchors>
</FontString>
<!-- "Do you wish to purchase another stable slot?" and the "Cost:" label
(xml l.161-178) — both hidden by Update once both slots are bought. -->
<FontString name="PetStableSlotText" inherits="GameFontHighlightSmall" text="STABLE_SLOT_TEXT">
<Anchors>
<Anchor point="BOTTOM"><Offset><AbsDimension x="-10" y="132"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="PetStableCostLabel" inherits="GameFontNormal" text="COSTS_LABEL">
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="74" y="110"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The model pane (ref `<PlayerModel name="PetStableModel">`, xml l.182-280): 313x223
at TOPLEFT (23,-76), the reference's own widget type since decision 1751 gave
`<Model>`/`<PlayerModel>` panes a draw of their own (portrait/mod.rs's
`MODEL_PANE_BOOTHS`).
The `$parentTexture` this used to carry is gone with it, and so is decision 1070's
BACKGROUND-not-ARTWORK note: that workaround existed because a booth *texture* is
an opaque region in a BUCKET-WIDE draw layer that outranks the frame (0884), so at
ARTWORK it painted over the diet icon below. The pane's own draw slot has no such
problem — it emits at the FRAME's z, which is what puts every child above it, and
that is the reference's own layering rather than a tie we had to arrange.
The pane keeps its reference NAME because the Lua passes it by name to
`SetPetStablePaperdoll` at four sites, an addon may too, and the app now joins the
booth to the pane by that name. -->
<PlayerModel name="PetStableModel">
<Size><AbsDimension x="313" y="223"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="23" y="-76"/></Offset></Anchor>
</Anchors>
<Frames>
<!-- Rotate buttons (xml l.206-247): 35x35, the left one at the pane's own
TOPLEFT and the right one chained off its TOPRIGHT, calling the
reference's own `Model_RotateLeft`/`Model_RotateRight` (UIParent.xml). -->
<Button name="PetStableModelRotateLeftButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<NormalTexture file="Interface\Buttons\UI-RotationLeft-Button-Up"/>
<PushedTexture file="Interface\Buttons\UI-RotationLeft-Button-Down"/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Round" alphaMode="ADD"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
<OnClick>Model_RotateLeft(this:GetParent())</OnClick>
</Scripts>
</Button>
<Button name="PetStableModelRotateRightButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="PetStableModelRotateLeftButton" relativePoint="TOPRIGHT"/>
</Anchors>
<NormalTexture file="Interface\Buttons\UI-RotationRight-Button-Up"/>
<PushedTexture file="Interface\Buttons\UI-RotationRight-Button-Down"/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Round" alphaMode="ADD"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
<OnClick>Model_RotateRight(this:GetParent())</OnClick>
</Scripts>
</Button>
<!-- The diet icon (xml l.248-278): 24x23 under the rotate buttons, its texcoords
STATIC. This is the pet's DIET affordance (its tooltip lists what it eats),
not a happiness readout — the happiness face that does move lives on the pet
unit frame (decisions 1005/0990), and PetPaperDollFrame.xml's twin carries
the same note.
Kept as a CHILD of the pane, where the reference has it, and not lifted to a
sibling the way the pet paper doll's was: Update hides the pane whenever
there is no pet to show, and being a child is what makes the icon go with
it. Declared after the pane's own quad, so at the shared BACKGROUND layer
the link stamp draws it on top. -->
<Frame name="PetStablePetInfo" enableMouse="true" hidden="false">
<Size><AbsDimension x="24" y="23"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="PetStableModelRotateLeftButton" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="7" y="0"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PetPaperDollFrame\UI-PetHappiness" setAllPoints="true">
<Size><AbsDimension x="24" y="23"/></Size>
<TexCoords left="0" right="0.1875" top="0" bottom="0.359375"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(this.tooltip)
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
</Frames>
<Scripts>
<!-- The reference's own pair (xml l.194-203): `Model_OnLoad` seeds the 0.61
default facing through `SetRotation`, and `Model_OnUpdate` is the
held-button continuous spin. The app mirrors this pane's facing onto the
"stable" booth each frame (`ui_stable`). -->
<OnLoad>Model_OnLoad()</OnLoad>
<OnUpdate>Model_OnUpdate(arg1, this)</OnUpdate>
</Scripts>
</PlayerModel>
<!-- The three slots (xml l.281-333). `id=` is what `this:GetID()` reads in the
template's handlers, and it IS the binding's slot index: 0 is the pet at your side,
1 and 2 the bought stable slots. The two labels are extra regions on the instances,
anchored above their buttons; note the reference hangs "Stabled Pets:" off slot 1
offset +24 so it centres over the PAIR. -->
<CheckButton name="PetStableCurrentPet" inherits="PetStableSlotTemplate" id="0">
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="95" y="150"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString inherits="GameFontNormalSmall" text="CURRENT_PET">
<Anchors>
<Anchor point="BOTTOM" relativePoint="TOP">
<Offset><AbsDimension x="0" y="6"/></Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
</CheckButton>
<CheckButton name="PetStableStabledPet1" inherits="PetStableSlotTemplate" id="1">
<Anchors>
<Anchor point="LEFT" relativeTo="PetStableCurrentPet" relativePoint="RIGHT">
<Offset><AbsDimension x="53" y="0"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString inherits="GameFontNormalSmall" text="STABLED_PETS">
<Anchors>
<Anchor point="BOTTOM" relativePoint="TOP">
<Offset><AbsDimension x="24" y="6"/></Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
</CheckButton>
<CheckButton name="PetStableStabledPet2" inherits="PetStableSlotTemplate" id="2">
<Anchors>
<Anchor point="LEFT" relativeTo="PetStableStabledPet1" relativePoint="RIGHT">
<Offset><AbsDimension x="15" y="0"/></Offset>
</Anchor>
</Anchors>
</CheckButton>
<!-- Buy the next stable slot (xml l.334-350). The server prices it and picks which slot
it is; Update decides whether this button is even on screen. -->
<Button name="PetStablePurchaseButton" inherits="UIPanelButtonTemplate" text="PURCHASE">
<Size><AbsDimension x="80" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-110" y="105"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>BuyStableSlot()</OnClick>
</Scripts>
</Button>
<!-- The player's purse (xml l.351-364). NO <Scripts> here: benilla's
SmallMoneyFrameTemplate already wires SmallMoneyFrame_OnLoad as its own OnLoad
where the reference's template does not, and an instance block would SetScript OVER
it rather than add to it (header, deferral 5). Type PLAYER by default, which is
what a purse wants. -->
<Frame name="PetStableMoneyFrame" inherits="SmallMoneyFrameTemplate">
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-45" y="84"/></Offset></Anchor>
</Anchors>
</Frame>
<!-- The next slot's price (xml l.365-379): a STATIC frame, so it shows whatever
MoneyFrame_Update last parked on it rather than tracking the purse. This one DOES
declare an OnLoad, and therefore has to re-call the template's loader beside
MoneyFrame_SetType (the AuctionFrame.xml/MailFrame.xml idiom). -->
<Frame name="PetStableCostMoneyFrame" inherits="SmallMoneyFrameTemplate">
<Anchors>
<Anchor point="LEFT" relativeTo="PetStableCostLabel" relativePoint="RIGHT">
<Offset><AbsDimension x="5" y="0"/></Offset>
</Anchor>
</Anchors>
<Scripts>
<OnLoad>SmallMoneyFrame_OnLoad() MoneyFrame_SetType("STATIC")</OnLoad>
</Scripts>
</Frame>
<!-- xml l.380-388. UIPanelCloseButton's own OnClick is HideUIPanel(this:GetParent()),
and this window's OnHide is what ends the session — so the X needs no handler. -->
<Button name="PetStableFrameCloseButton" inherits="UIPanelCloseButton">
<Anchors>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-29" y="-8"/></Offset></Anchor>
</Anchors>
</Button>
</Frames>
<!-- xml l.390-404. The NPC-session lifecycle: OnHide fires ClosePetStables() so the session
ends whenever this window hides for ANY reason (ESC, the X, PET_STABLE_CLOSED, or
another left-slot window displacing it). Unlike its neighbours that close is purely
client-side — no packet exists for it in 5875. -->
<Scripts>
<OnLoad>PetStable_OnLoad()</OnLoad>
<OnShow>PlaySound("igCharacterInfoOpen")</OnShow>
<OnHide>ClosePetStables() PlaySound("igCharacterInfoClose")</OnHide>
<OnEvent>PetStable_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
@@ -1071,6 +1071,44 @@
-- `doNotScroll` (pin §5's asymmetry, threaded through to `BenillaQuestLogDetail_Update`):
-- absent/falsy for a manual call (a row click, the window's own OnShow); `true` from the
-- QUEST_LOG_UPDATE event handler below.
-- ── The reference's own two names for what this window already does ─────────────────
--
-- `QuestTimerFrame.xml` is the reference's own file, on the chain since 1751, and its
-- `QuestTimerButton_OnClick` (QuestTimerFrame.lua:41-45) does exactly three things:
-- ShowUIPanel, `QuestLog_SetSelection(...)`, `QuestLog_Update()`. Neither of the last two
-- existed anywhere in this tree, so clicking a quest timer raised. Defining them here is
-- not a stub of an engine verb (1203) — both are FrameXML functions, and this file is our
-- stand-in for `QuestLogFrame.lua`, which is where the reference defines them. Every other
-- function in this window has the same relationship to its stock twin; these two were
-- simply missed because nothing of ours called them.
--
-- `QuestLog_SetSelection`'s stock body (QuestLogFrame.lua:317-345) is mostly frame work
-- that only the stock window has — `QuestLogTitle`..id, `QuestLogHighlightFrame`,
-- `QuestLogSkillHighlight`. What survives the re-expression is its LAW: a header never
-- becomes the selection (it folds instead), and a real quest is selected and repainted.
-- That is the same fork `BenillaQuestLogTitle_OnClick` above already implements, so this
-- is the shared body rather than a second copy of it.
function QuestLog_SetSelection(index)
if not index or index == 0 then return end
local _, _, _, isHeader, isCollapsed = GetQuestLogTitle(index)
if isHeader then
if isCollapsed and type(ExpandQuestHeader) == "function" then
ExpandQuestHeader(index)
elseif type(CollapseQuestHeader) == "function" then
CollapseQuestHeader(index)
end
return
end
SelectQuestLogEntry(index)
end
-- The reference's `QuestLog_Update` (QuestLogFrame.lua:107). Ours has carried this body
-- under a `Benilla` prefix; the stock name is the one the chain calls, and having it here
-- also shrinks the delta when this window itself migrates.
function QuestLog_Update(doNotScroll)
BenillaQuestLogFrame_Update(doNotScroll)
end
function BenillaQuestLogFrame_Update(doNotScroll)
QuestLog_SetFirstValidSelection()
BenillaQuestLogList_Update()
-557
View File
@@ -1,557 +0,0 @@
<Ui>
<!--
benilla's taxi-map window (decision 0484 phase 2), written on the taxi engine seam
(`crates/benilla-ui/src/script/taxi.rs`) fed by `crates/benilla/src/ui_taxi.rs`.
This is **our implementation**. Its specification is the real 1.12 `TaxiFrame.{xml,lua}`
(patch chain; scratchpad `ref-TaxiFrame.xml/.lua`, line citations inline as `ref l.N`): the
globals, the art paths, the geometry and the behaviour of every branch are the
specification's, because addons and the engine both address this window by those names, and
because the numbers are measurements of the shipped art (384×512, the four TaxiFrame
quadrant slabs at a 256/128 split, the 316×352 map rect at TOP -13,-75) rather than
anything for us to re-derive. How it is written is ours. The deviations below are where
THIS ENGINE, not taste, draws the line, each flagged and reasoned like every other window
of ours (Gossip/Merchant/Trainer's own header notes).
Modeled on MerchantFrame.xml/TrainerFrame.xml for the framing: the NPC-session lifecycle
(RegisterEvent pair, OPENED/CLOSED dispatch, OnShow/OnHide PlaySound with a client-side
close on hide), the "npc" model-portrait binding, the minimize-button close X (this engine
ships no UIPanelCloseButton), and the UIPanel slot system (NO hardcoded `<Anchors>` on the
root — ShowUIPanel/SetLeftFrame SetPoints it to the left slot on open, exactly like every
other NPC window).
DEVIATIONS from the literal reference, each forced by this engine (never taste):
1. NODE BUTTONS ARE A STATIC POOL, not `CreateFrame(..., "TaxiButtonTemplate")` grown on
demand (ref l.36-42/TaxiFrame.lua:36-42). This engine's runtime `CreateFrame`'s 4th
`inherits` argument is explicitly a no-op — template expansion is the XML LOADER's job
only (`benilla-ui/src/script/object.rs`'s `create_frame`: "inherits/template expansion
is the XML loader's job — ignored"). Every other list window in this codebase hits the
identical wall and solves it identically: Gossip's 8-row pool, Trainer's service-row
pool, QuestLog's row pool — a fixed set of concrete `inherits=` instances (which DOES
expand at LOAD time), shown/hidden/repositioned per repaint instead of created on
demand. `BENILLA_TAXI_MAX_NODES = 50` below comfortably covers the real 5875
`TaxiNodes.dbc`'s per-continent max (43 nodes on Eastern Kingdoms, 39 on Kalimdor —
counted this session, `crate::ui_taxi`'s module doc). The route-line textures stay
genuinely dynamic (`TaxiRouteMap:CreateTexture(...)`, ref l.101/142) — plain
region creation carries no template/inherits, so that half is created on demand exactly
as specified.
2. THE TITLE READS AN EVENT ARG, not `UnitName("npc")` (ref TaxiFrame.lua:27). This
engine's `"npc"` unit token (decision 0421) only feeds the PORTRAIT bake — there is no
backing `UnitState` for `UnitName` to read from it. Gossip/Merchant/Trainer hit the
identical gap and all solve it the same way: the app resolves the flight master's name
through the `NameCache` and rides it in as the open event's arg1 — done here too
(`BenillaTaxiFrame_SetTitle`, fed by `TAXIMAP_OPENED`'s arg1).
3. (Retired.) The "no single-hop destination" refusal (`DrawOneHopLines`, ref l.161-164)
used to call a plain `BenillaErrorsFrame_AddMessage` global because this engine had no
MessageFrame widget for `UIErrorsFrame` to be. It has one now, so the refusal below posts
to `UIErrorsFrame` under the specified name. `ErrorsFrame.xml` loads well before this file
in the shipped list (`ui_script/mod.rs`), so the frame already exists by the time this
ever runs.
`<TaxiRouteFrame>` (an intrinsic type this engine doesn't have) becomes a plain `<Frame>`
(`TaxiRouteMap`) per the house rule for every non-shipped intrinsic (decision 0246's
patch-chain rule; the `Cooldown`/`GameTooltip`/etc. precedents) — it carries no behavior of
its own beyond being a Frame that owns child regions/buttons, so the plain type loses
nothing.
Money (the fare) renders as plain text via `SetTooltipMoney` on the shared `GameTooltip`
(`GameTooltip.xml`'s own seam) exactly as specified — no `BenillaMoney_*` coin rig needed
here (that lives on the merchant/trainer purse displays, a different job).
-->
<Script><![CDATA[
-- The continent map's size in pixels — a published pair, because scaling a node's
-- normalised position onto the map needs them and so does anything that wants to put its own
-- marker there. The values are the specification's (ref TaxiFrame.lua:2-3): a measurement of
-- the shipped map art.
TAXI_MAP_WIDTH = 316
TAXI_MAP_HEIGHT = 352
-- The static node-button pool's size — see deviation 1 above.
BENILLA_TAXI_MAX_NODES = 50
-- What each node state draws (ref TaxiFrame.lua:7-16): CURRENT green, REACHABLE white,
-- DISTANT yellow — all three art files verified present in the MPQ. Published as a table of
-- `{ file = }` keyed by the state string `TaxiNodeGetType` answers, which is both the shape
-- the node paint below reads and the shape an addon adding a state of its own writes.
TaxiButtonTypes = {
CURRENT = { file = "Interface\\TaxiFrame\\UI-Taxi-Icon-Green" },
REACHABLE = { file = "Interface\\TaxiFrame\\UI-Taxi-Icon-White" },
DISTANT = { file = "Interface\\TaxiFrame\\UI-Taxi-Icon-Yellow" },
}
-- The two strings this window shows, byte-exact from the 1.12 enUS GlobalStrings
-- (interface.MPQ `GlobalStrings.lua`, line-cited) — user-visible text, so its bytes are the
-- spec, not our expression. `TEXT(x)` (ref l.121) is the real client's build-time no-op
-- localization macro, so every `TEXT(FOO)` resolves to the bare `FOO` global
-- (CharacterFrame.xml's own convention); with no engine `TEXT` global at all, and nothing
-- outside this window reading either string, they are plain locals here instead.
local TAXINODEYOUAREHERE = "You are here" -- GlobalStrings.lua:4232
-- GlobalStrings.lua:1864 — the reference's own byte, including its curly apostrophe
-- (U+2019, "don\226\128\153t"), not a straight one.
local ERR_TAXINOPATHS = "You don\226\128\153t know any flight locations connected to this one."
function TaxiFrame_OnLoad()
local frame = this
-- The "npc" portrait token (decision 0421): the open taxi session's flight master
-- bakes into the ring, resolved exactly like Gossip/Merchant/Trainer's own OnLoad.
SetPortraitTexture(getglobal(frame:GetName() .. "Portrait"), "npc")
frame:RegisterEvent("TAXIMAP_OPENED")
frame:RegisterEvent("TAXIMAP_CLOSED")
end
-- Deviation 2 above: the flight master's name rides TAXIMAP_OPENED's arg1 (the app
-- resolves it through the NameCache) rather than a live UnitName("npc") read.
function BenillaTaxiFrame_SetTitle(name)
getglobal("TaxiNameText"):SetText(name or "")
end
-- Paint the node buttons (ref l.44-63): every pool slot up to the pushed node count takes
-- that node's state icon and its pushed position, and the rest of the pool hides. Positions
-- arrive normalised 0..1 from the map's BOTTOMLEFT, which is what the map size above scales.
local function PaintNodes()
local pushed = NumTaxiNodes()
for index = 1, BENILLA_TAXI_MAX_NODES do
local button = getglobal("TaxiButton" .. index)
local state = TaxiNodeGetType(index)
if index <= pushed and state ~= "NONE" then
local x, y = TaxiNodePosition(button:GetID())
button:ClearAllPoints()
button:SetPoint("CENTER", "TaxiMap", "BOTTOMLEFT", x * TAXI_MAP_WIDTH, y * TAXI_MAP_HEIGHT)
button:SetNormalTexture(TaxiButtonTypes[state].file)
button:Show()
else
button:Hide()
end
end
end
-- Opening and closing the window (ref TaxiFrame.lua:24-80). Opening repaints everything from
-- the pushed snapshot and then asks the panel manager for a slot; if it will not give one,
-- the session is closed again rather than left running behind a window nobody can see.
function TaxiFrame_OnEvent(event)
local frame = this
if event == "TAXIMAP_OPENED" then
BenillaTaxiFrame_SetTitle(arg1)
-- Reset the map's sampling, then hand the region to the app's continent-art pick.
TaxiMap:SetTexCoord(0, 1, 0, 1)
SetTaxiMap(TaxiMap)
PaintNodes()
ShowUIPanel(frame)
if not frame:IsVisible() then
CloseTaxiMap()
end
return
end
if event == "TAXIMAP_CLOSED" then
HideUIPanel(frame)
return
end
end
-- ── The route-line pool ──────────────────────────────────────────────────────────────────
--
-- A flight path draws as a chain of straight segments, one texture per segment. Those
-- textures are a pool that grows to the longest chain ever asked for and is never shrunk:
-- `NUM_TAXI_ROUTES` is how many exist and `TaxiRoute<n>` is each one's global name — both
-- published, since the count is a global the specification exports and the names are what
-- `getglobal` finds. Unlike the node buttons, this half really is created on demand: plain
-- region creation carries no template, so deviation 1's CreateFrame wall never applies.
NUM_TAXI_ROUTES = 0
-- Grow the pool to hold at least `n` lines; answer what it holds.
local function GrowRoutePool(n)
while NUM_TAXI_ROUTES < n do
NUM_TAXI_ROUTES = NUM_TAXI_ROUTES + 1
local line = TaxiRouteMap:CreateTexture("TaxiRoute" .. NUM_TAXI_ROUTES, "BACKGROUND")
line:SetTexture("Interface\\TaxiFrame\\UI-Taxi-Line")
end
return NUM_TAXI_ROUTES
end
-- Draw one line per `{ node, hop }` in `segments`, and hide whatever the pool holds beyond
-- them. Each hop's endpoints arrive normalised on the map, so they scale by the canvas the
-- lines live on. 32 is the specified line thickness (ref l.114/153), in those same pixels.
local function PaintRoutes(segments)
local count = table.getn(segments)
local pool = GrowRoutePool(count)
local w = TaxiRouteMap:GetWidth()
local h = TaxiRouteMap:GetHeight()
for i = 1, pool do
local line = getglobal("TaxiRoute" .. i)
if i <= count then
local node, hop = segments[i][1], segments[i][2]
DrawRouteLine(line, "TaxiRouteMap",
TaxiGetSrcX(node, hop) * w, TaxiGetSrcY(node, hop) * h,
TaxiGetDestX(node, hop) * w, TaxiGetDestY(node, hop) * h, 32)
line:Show()
else
line:Hide()
end
end
end
-- The tooltip, and the route, for the node under the cursor (ref TaxiFrame.lua:82-126). A
-- reachable node prices the flight and draws every hop of it; the node we are standing on
-- says so and draws the whole neighbourhood instead.
--
-- `this:GetID()` in the fare read is the SAME button as the `button` parameter — the engine
-- binds the shared `this` global to the invoking frame before OnEnter runs (decision 0068's
-- event-dispatch convention), exactly like the real client, so the two reads agree.
function TaxiNodeOnButtonEnter(button)
local index = button:GetID()
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
GameTooltip:AddLine(TaxiNodeName(index), "", 1.0, 1.0, 1.0)
local state = TaxiNodeGetType(index)
if state == "REACHABLE" then
SetTooltipMoney(GameTooltip, TaxiNodeCost(this:GetID()))
TaxiNodeSetCurrent(index)
local hops = { }
for hop = 1, GetNumRoutes(index) do
hops[hop] = { index, hop }
end
PaintRoutes(hops)
elseif state == "CURRENT" then
GameTooltip:AddLine(TAXINODEYOUAREHERE, "", 0.5, 1.0, 0.5)
DrawOneHopLines()
end
GameTooltip:Show()
end
-- Every flight path within one hop of where we stand (ref TaxiFrame.lua:129-165) — what the
-- map shows the moment it opens, before anything is hovered. A node exactly one segment away
-- is one this flight master can send us to directly.
function DrawOneHopLines()
local hops = { }
for node = 1, NumTaxiNodes() do
if GetNumRoutes(node) == 1 then
table.insert(hops, { node, 1 })
end
end
PaintRoutes(hops)
-- A flight point with nothing one hop away is a dead end: say so, and take the window
-- back down rather than leave an empty map up.
if table.getn(hops) == 0 then
UIErrorsFrame:AddMessage(ERR_TAXINOPATHS, 1.0, 0.1, 0.1)
HideUIPanel(TaxiFrame)
end
end
-- ── Route lines ───────────────────────────────────────────────────────────────────────
--
-- A flight path is one straight line between two map points, drawn as a texture. The
-- engine can only place an AXIS-ALIGNED rectangle, so the rotation has to live in the
-- texture coordinates instead: SetTexCoord's 8-argument form places each of the four
-- source corners independently, which is exactly enough to spin (and shear) the art
-- inside its bounding box. The bounding box then has to grow to hold the rotated line.
--
-- The geometry — how a rotation angle turns into those four corners and that box — is
-- the specification, and it is the reference's (ref TaxiFrame.lua:168-226; the credit
-- their copy carries is to Daniel Stephens <iriel@vigilance-committee.org>). Every value
-- below is theirs, and the multiplications are grouped the way theirs are so the rounding
-- matches; how it is written — the naming, the shear terms hoisted out of both branches,
-- the anchoring helper — is ours. The reference derives each corner from the last
-- (`BRx = 1 - BLy`, `TRy = BRx`, …); we state each one directly instead, which is the
-- same number everywhere but one: the downward branch's top-left U is `-shearU` here
-- against their `1 - (1 + shearU)`. MEASURED, both implementations run side by side on
-- the real Lua 5.1 over 50k randomised segments plus the degenerate/axis-aligned/reversed
-- edges: every anchor identical to the last bit, and every texture coordinate identical
-- once narrowed to f32 — which the engine does to all eight on the way in
-- (`region::paint`'s `Variadic<f32>`). The lone survivor is the SIGN OF ZERO on an exactly
-- vertical downward line, where that corner is -0 here and +0 there: the same coordinate,
-- and the same sample.
--
-- Engine seam, all three proved by the phase-1 seam test: CreateTexture, SetTexCoord's
-- 8-arg form, and SetPoint against a canvas named by string.
-- The reference's two scale globals, value for value. Only the halved one is ever read —
-- by the bounding-box extents below, which are half-widths, hence the /2; the whole one
-- is a published global that nothing in the reference reads either. 32/30 is unexplained
-- there and carried unchanged here — a measurement of the shipped art, not ours to
-- re-derive.
TAXIROUTE_LINEFACTOR = 32/30
TAXIROUTE_LINEFACTOR_2 = TAXIROUTE_LINEFACTOR / 2
-- Seat `texture` as the box centred on (cx, cy) that reaches `halfW` / `halfH` out along
-- each axis, measured from `canvas`'s `relPoint` corner. Both ends always move together,
-- so clearing first is what keeps a stale anchor from outliving a move. (The reference
-- re-seats the two points without clearing on its degenerate path; a route texture only
-- ever holds these two anchors and SetPoint replaces one of the same name, so the box
-- that comes out is the same either way.)
local function AnchorCenteredBox(texture, canvas, relPoint, cx, cy, halfW, halfH)
texture:ClearAllPoints()
texture:SetPoint("BOTTOMLEFT", canvas, relPoint, cx - halfW, cy - halfH)
texture:SetPoint("TOPRIGHT", canvas, relPoint, cx + halfW, cy + halfH)
end
-- Draw the segment (sx,sy) -> (ex,ey), `width` thick, into `texture`, placed against
-- `canvas` in the coordinate space of its `relPoint` corner (BOTTOMLEFT by default).
function DrawRouteLine(texture, canvas, sx, sy, ex, ey, width, relPoint)
relPoint = relPoint or "BOTTOMLEFT"
local dx, dy = ex - sx, ey - sy
local cx, cy = (sx + ex) / 2, (sy + ey) / 2
-- A segment drawn back-to-front is the same segment: face it rightwards so only the
-- two upward/downward cases below have to exist.
if dx < 0 then
dx, dy = -dx, -dy
end
local len = sqrt((dx * dx) + (dy * dy))
if len == 0 then
-- Nothing to draw: collapse the quad onto its own midpoint and show no art.
texture:SetTexCoord(0, 0, 0, 0, 0, 0, 0, 0)
AnchorCenteredBox(texture, canvas, relPoint, cx, cy, 0, 0)
return
end
-- Sine and cosine of the segment's angle, and the two shear terms every corner is
-- built from — one walking the texture's horizontal axis, one its vertical axis.
local sinA, cosA = -dy / len, dx / len
local sinSq = sinA * sinA
local shear = sinA * cosA
local shearU = (width / len) * shear
local shearV = (len / width) * shear
-- Half the bounding box the rotated line needs, and the four source corners that lay
-- the art across it. Upward and downward are the same algebra mirrored: the four
-- quantities are the same, and which corner each lands on is what flips.
local halfW, halfH
local blU, blV, tlU, tlV, trU, trV, brU, brV
if dy >= 0 then
halfW = ((len * cosA) - (width * sinA)) * TAXIROUTE_LINEFACTOR_2
halfH = ((width * cosA) - (len * sinA)) * TAXIROUTE_LINEFACTOR_2
blU, blV = shearU, sinSq
tlU, tlV = sinSq, 1 - shearV
trU, trV = 1 - shearU, 1 - sinSq
brU, brV = 1 - sinSq, shearV
else
halfW = ((len * cosA) + (width * sinA)) * TAXIROUTE_LINEFACTOR_2
halfH = ((width * cosA) + (len * sinA)) * TAXIROUTE_LINEFACTOR_2
blU, blV = sinSq, -shearV
tlU, tlV = -shearU, 1 - sinSq
trU, trV = 1 - sinSq, 1 + shearV
brU, brV = 1 + shearU, sinSq
end
-- SetTexCoord's corner order is top-left, bottom-left, top-right, bottom-right.
texture:SetTexCoord(tlU, tlV, blU, blV, trU, trV, brU, brV)
AnchorCenteredBox(texture, canvas, relPoint, cx, cy, halfW, halfH)
end
-- Appearing and disappearing. The sounds are the MainMenu pair, not the CharacterInfo pair
-- Merchant/Trainer use — the specification genuinely names a different one for this window
-- (ref l.162-169). Showing also draws the neighbourhood straight away, so an untouched map
-- is not blank; hiding ends the session, which is what fires TAXIMAP_CLOSED back at us.
function BenillaTaxiFrame_OnShow()
PlaySound("igMainMenuOpen")
DrawOneHopLines()
end
function BenillaTaxiFrame_OnHide()
CloseTaxiMap()
PlaySound("igMainMenuClose")
end
function BenillaTaxiCloseButton_OnClick()
-- vanilla's client-side close sends no packet; the app clears TaxiState on this
-- intent (which fires TAXIMAP_CLOSED), and we hide immediately so there's no lag.
CloseTaxiMap()
HideUIPanel(TaxiFrame)
end
]]></Script>
<!--
The node-button pool's virtual template (ref TaxiButtonTemplate, l.4-32): a 16×16 button,
the state icon assigned per repaint via SetNormalTexture (deviation 1's static pool — see
the header note), the 32×32 CENTER highlight glow. The three handlers dispatch to the same
three verbs the specification's do (l.12-20) — including the click firing UNCONDITIONALLY
regardless of node state (a DISTANT/CURRENT click still queues TakeTaxiNode; the app-side
drain treats a routeless click as a no-op — there is no RE'd finding for what the real
client's native TakeTaxiNode does with an unreachable index, so this is the safe,
undocumented-but-reasonable inference, not an asserted fact).
-->
<Button name="BenillaTaxiButtonTemplate" hidden="true" virtual="true">
<Size>
<AbsDimension x="16" y="16"/>
</Size>
<Anchors>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Scripts>
<OnEnter>
TaxiNodeOnButtonEnter(this)
</OnEnter>
<OnLeave>
GameTooltip:Hide()
</OnLeave>
<OnClick>
TakeTaxiNode(this:GetID())
</OnClick>
</Scripts>
<HighlightTexture file="Interface\TaxiFrame\UI-Taxi-Icon-Highlight">
<Size x="32" y="32"/>
<Anchors>
<Anchor point="CENTER">
<Offset>
<AbsDimension x="0" y="0"/>
</Offset>
</Anchor>
</Anchors>
</HighlightTexture>
</Button>
<Frame name="TaxiFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
<!-- The real 384×512 window (ref l.34-36). No <Anchors> — ShowUIPanel/SetLeftFrame
SetPoints this to the left slot on open, exactly like Gossip/Merchant/Trainer. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<!-- NPC portrait 58×58 TOPLEFT (8,-9) (ref TaxiPortrait, l.46-57), BACKGROUND —
drawn under the ARTWORK quadrant slabs below so their frame art rings it. -->
<Layer level="BACKGROUND">
<Texture name="$parentPortrait">
<Size><AbsDimension x="58" y="58"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="8" y="-9"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<!-- The four TaxiFrame quadrant slabs that ARE the window art (ref l.59-103): 256-wide
left halves, 128-wide right halves, each 256 tall, pinned to their corner. No
<TexCoords> — each samples its whole texture. -->
<Layer level="ARTWORK">
<Texture file="Interface\TaxiFrame\UI-TaxiFrame-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\TaxiFrame\UI-TaxiFrame-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="256" y="0"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\TaxiFrame\UI-TaxiFrame-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-256"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\TaxiFrame\UI-TaxiFrame-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="256" y="-256"/></Offset></Anchor></Anchors>
</Texture>
<!-- The flight master's name banner (ref TaxiMerchant, l.104-115): GameFontNormal,
CENTER, TOP -17, 192 wide — deviation 2 above (arg1, not UnitName("npc")). -->
<FontString name="TaxiNameText" inherits="GameFontNormal" justifyH="CENTER">
<Size><AbsDimension x="192" y="0"/></Size>
<Anchors>
<Anchor point="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
<!-- The continent map art (ref TaxiMap, l.117-129): 316×352, TOP -13,-75. No file —
SetTaxiMap assigns the app's TAXIMAP<continentId> pick (byte-verified — decision 0496). -->
<Layer level="OVERLAY">
<Texture name="TaxiMap">
<Size><AbsDimension x="316" y="352"/></Size>
<Anchors>
<Anchor point="TOP"><Offset><AbsDimension x="-13" y="-75"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- Close: the real UIPanelCloseButton position (TOPRIGHT -29,-8; ref l.133-141), on
the minimize-button art (this engine ships no UIPanelCloseButton — the Gossip/
Trainer/Merchant precedent). -->
<Button name="TaxiCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-29" y="-8"/></Offset></Anchor>
</Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
<Scripts><OnClick>BenillaTaxiCloseButton_OnClick()</OnClick></Scripts>
</Button>
<!-- `<TaxiRouteFrame>` (ref l.142-153) — an intrinsic type this engine doesn't have —
becomes a plain <Frame> (see the header note): 316×352, TOP -13,-75, exactly
overlaid on TaxiMap. Houses the node-button pool + the dynamically-created
route-line textures, both parented here exactly like the reference (ref l.39,101). -->
<Frame name="TaxiRouteMap">
<Size><AbsDimension x="316" y="352"/></Size>
<Anchors>
<Anchor point="TOP"><Offset><AbsDimension x="-13" y="-75"/></Offset></Anchor>
</Anchors>
<Frames>
<Button name="TaxiButton1" inherits="BenillaTaxiButtonTemplate" id="1"/>
<Button name="TaxiButton2" inherits="BenillaTaxiButtonTemplate" id="2"/>
<Button name="TaxiButton3" inherits="BenillaTaxiButtonTemplate" id="3"/>
<Button name="TaxiButton4" inherits="BenillaTaxiButtonTemplate" id="4"/>
<Button name="TaxiButton5" inherits="BenillaTaxiButtonTemplate" id="5"/>
<Button name="TaxiButton6" inherits="BenillaTaxiButtonTemplate" id="6"/>
<Button name="TaxiButton7" inherits="BenillaTaxiButtonTemplate" id="7"/>
<Button name="TaxiButton8" inherits="BenillaTaxiButtonTemplate" id="8"/>
<Button name="TaxiButton9" inherits="BenillaTaxiButtonTemplate" id="9"/>
<Button name="TaxiButton10" inherits="BenillaTaxiButtonTemplate" id="10"/>
<Button name="TaxiButton11" inherits="BenillaTaxiButtonTemplate" id="11"/>
<Button name="TaxiButton12" inherits="BenillaTaxiButtonTemplate" id="12"/>
<Button name="TaxiButton13" inherits="BenillaTaxiButtonTemplate" id="13"/>
<Button name="TaxiButton14" inherits="BenillaTaxiButtonTemplate" id="14"/>
<Button name="TaxiButton15" inherits="BenillaTaxiButtonTemplate" id="15"/>
<Button name="TaxiButton16" inherits="BenillaTaxiButtonTemplate" id="16"/>
<Button name="TaxiButton17" inherits="BenillaTaxiButtonTemplate" id="17"/>
<Button name="TaxiButton18" inherits="BenillaTaxiButtonTemplate" id="18"/>
<Button name="TaxiButton19" inherits="BenillaTaxiButtonTemplate" id="19"/>
<Button name="TaxiButton20" inherits="BenillaTaxiButtonTemplate" id="20"/>
<Button name="TaxiButton21" inherits="BenillaTaxiButtonTemplate" id="21"/>
<Button name="TaxiButton22" inherits="BenillaTaxiButtonTemplate" id="22"/>
<Button name="TaxiButton23" inherits="BenillaTaxiButtonTemplate" id="23"/>
<Button name="TaxiButton24" inherits="BenillaTaxiButtonTemplate" id="24"/>
<Button name="TaxiButton25" inherits="BenillaTaxiButtonTemplate" id="25"/>
<Button name="TaxiButton26" inherits="BenillaTaxiButtonTemplate" id="26"/>
<Button name="TaxiButton27" inherits="BenillaTaxiButtonTemplate" id="27"/>
<Button name="TaxiButton28" inherits="BenillaTaxiButtonTemplate" id="28"/>
<Button name="TaxiButton29" inherits="BenillaTaxiButtonTemplate" id="29"/>
<Button name="TaxiButton30" inherits="BenillaTaxiButtonTemplate" id="30"/>
<Button name="TaxiButton31" inherits="BenillaTaxiButtonTemplate" id="31"/>
<Button name="TaxiButton32" inherits="BenillaTaxiButtonTemplate" id="32"/>
<Button name="TaxiButton33" inherits="BenillaTaxiButtonTemplate" id="33"/>
<Button name="TaxiButton34" inherits="BenillaTaxiButtonTemplate" id="34"/>
<Button name="TaxiButton35" inherits="BenillaTaxiButtonTemplate" id="35"/>
<Button name="TaxiButton36" inherits="BenillaTaxiButtonTemplate" id="36"/>
<Button name="TaxiButton37" inherits="BenillaTaxiButtonTemplate" id="37"/>
<Button name="TaxiButton38" inherits="BenillaTaxiButtonTemplate" id="38"/>
<Button name="TaxiButton39" inherits="BenillaTaxiButtonTemplate" id="39"/>
<Button name="TaxiButton40" inherits="BenillaTaxiButtonTemplate" id="40"/>
<Button name="TaxiButton41" inherits="BenillaTaxiButtonTemplate" id="41"/>
<Button name="TaxiButton42" inherits="BenillaTaxiButtonTemplate" id="42"/>
<Button name="TaxiButton43" inherits="BenillaTaxiButtonTemplate" id="43"/>
<Button name="TaxiButton44" inherits="BenillaTaxiButtonTemplate" id="44"/>
<Button name="TaxiButton45" inherits="BenillaTaxiButtonTemplate" id="45"/>
<Button name="TaxiButton46" inherits="BenillaTaxiButtonTemplate" id="46"/>
<Button name="TaxiButton47" inherits="BenillaTaxiButtonTemplate" id="47"/>
<Button name="TaxiButton48" inherits="BenillaTaxiButtonTemplate" id="48"/>
<Button name="TaxiButton49" inherits="BenillaTaxiButtonTemplate" id="49"/>
<Button name="TaxiButton50" inherits="BenillaTaxiButtonTemplate" id="50"/>
</Frames>
</Frame>
</Frames>
<Scripts>
<OnLoad>
TaxiFrame_OnLoad()
</OnLoad>
<OnEvent>
TaxiFrame_OnEvent(event)
</OnEvent>
<OnShow>
BenillaTaxiFrame_OnShow()
</OnShow>
<OnHide>
BenillaTaxiFrame_OnHide()
</OnHide>
</Scripts>
</Frame>
</Ui>
+20 -6
View File
@@ -755,11 +755,19 @@ TrainerFrame.xml
#
# Kept in the "NPC session windows grouped" cluster beside Trainer/Taxi, and after MerchantFrame.
Interface\FrameXML\BankFrame.xml
# The taxi-map window (decision 0484 phase 2): the flight-master session, driven by the
# taxi engine seam (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain.
# Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
# needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney), both already loaded above.
TaxiFrame.xml
# ══ THE FLIGHT MAP — the reference's own, off the player's chain (1751) ══
# The flight-master session (decision 0484 phase 2), driven by the taxi engine seam
# (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain — unchanged; only the window
# in front of it is the reference's now. Our 557-line transcription carried the same five stock
# globals plus eight helpers of its own, none of which anything outside that file named.
#
# The one thing it needed was the `<TaxiRouteFrame>` widget type, which is registered now (1828) —
# a `CSimpleFrame` and nothing else, so it is `FrameKind::Frame` and the route lines stay what the
# reference makes them: plain `<Texture>` children rotated by an 8-argument `SetTexCoord`.
#
# Pulls only `TaxiFrame.lua`, and still needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney),
# all loaded above — so it keeps its seat in the "NPC session windows grouped" cluster.
Interface\FrameXML\TaxiFrame.xml
# The stable-master window (decision 1676): the hunter's three-slot pet stable over the stable
# API (`benilla-ui/src/script/stable.rs`) + the app's feed/drain. Kept in the "NPC session
# windows grouped" cluster beside Trainer/Bank/Taxi. Two of its dependencies are resolved at
@@ -771,7 +779,13 @@ TaxiFrame.xml
# longer has to, and stays here only because the NPC-session cluster is where it belongs. The rest
# (ItemButtonTemplate's SetItemButtonTexture, GameTooltip, UiPanels' ShowUIPanel/HideUIPanel,
# UIParent's BuildListString) are resolved when the window repaints, so their order is free.
PetStable.xml
#
# ══ THE REFERENCE'S OWN, off the player's chain (1751) ══
# A true 1:1 swap: our transcription and the stock file declare the SAME frame names and the SAME
# four globals (`PetStable_OnLoad`/`_OnEvent`/`_Update`/`_NoPetsAllowed`), so nothing that names
# either moves — `ui_stable.rs`, `names.rs` and `net/apply/npc.rs` all reach it by stock names
# already, and `portrait`'s booth keys on `PetStableModel`, which both spell the same.
Interface\FrameXML\PetStable.xml
# The auction house (decision 1511): the three-tab doublewide window over the auction API
# (`benilla-ui/src/script/auction.rs`) + the app's feed/drain. Kept in the "NPC session
# windows grouped" cluster beside Trainer/Bank/Taxi, and loaded AFTER MoneyFrame (its eight
+9 -2
View File
@@ -42,6 +42,7 @@ pub(super) struct WorldReadout<'w, 's> {
room: Res<'w, benilla_world::wmo_portal::CameraInteriorClaim>,
windows: Res<'w, benilla_world::wmo_portal::ExteriorWindows>,
skybox: Res<'w, benilla_world::skybox::CameraSkybox>,
skybox_weight: Res<'w, benilla_world::skybox::SkyboxWeight>,
streamer: Res<'w, benilla_world::terrain_stream::TerrainStreamer>,
self_guid: Res<'w, crate::net::SelfGuid>,
names: ResMut<'w, crate::names::NameCache>,
@@ -100,11 +101,17 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) {
// The WMO skybox engagement, right beside the interior claim it derives from: both come off
// the camera's down-ray seed, so when the backdrop flips between the building's painted sky
// and the Light.dbc gradient, this is the line that says which — and whether the claim moved
// under it. From the chair the two are only distinguishable by colour.
// under it. From the chair the two are only distinguishable by colour. The weight is the
// 4-second crossfade (== the interior-fog blend, one number): `w 0.00` beside a name means
// the flood published a skybox the crossfade hasn't engaged — faithful standing outside
// the gate, not a resolve bug.
match world.skybox.0.as_deref() {
Some(path) => {
let leaf = path.rsplit('\\').next().unwrap_or(path);
line.push_str(&format!(" · skybox {leaf}"));
line.push_str(&format!(
" · skybox {leaf} w {:.2}",
world.skybox_weight.0
));
}
None => line.push_str(" · sky gradient"),
}
+6 -2
View File
@@ -1860,8 +1860,12 @@ pub(super) fn apply_net_updates(
new_count,
..
} => npc::vendor_buy_result(vendor, slot, new_count, &mut merchant),
SessionEvent::VendorBuyFailed { reason, .. } => {
npc::vendor_buy_failed(reason, &mut ui_actions.3)
SessionEvent::VendorBuyFailed {
vendor,
item_entry,
reason,
} => {
npc::vendor_buy_failed(vendor, item_entry, reason, &mut merchant, &mut ui_actions.3)
}
SessionEvent::VendorSellFailed { reason, .. } => {
npc::vendor_sell_failed(reason, &mut ui_actions.3)
+27 -3
View File
@@ -306,9 +306,33 @@ pub(super) fn vendor_buy_result(
}
}
/// A purchase was refused (`SMSG_BUY_FAILED`) — the merchant window's error line.
pub(super) fn vendor_buy_failed(reason: u8, errors: &mut MerchantErrors) {
debug!("net: buy failed (reason {reason})");
/// A purchase was refused (`SMSG_BUY_FAILED`) — the merchant window's error line, and for the
/// out-of-stock code the refusing row's own count.
///
/// **`ITEM_ALREADY_SOLD` zeroes the row** (`0x5dcda7`..`0x5dcdd6`, decision 1821): the reference
/// walks its 128-row vendor cache, writes 0 into the matching row's count word and repaints —
/// gated, as every other stale-answer path here is, on the packet naming the vendor still open.
///
/// **NAMED DIVERGENCE in the key.** The reference matches the row's `+0x00`, the vendor *slot* —
/// the same word `SMSG_BUY_ITEM` keys its stock update by. vmangos puts the item *entry* in that
/// field (`Player::SendBuyError`, `Player.cpp:11637`: `packet->itemEntry = item`), so matching by
/// slot would find nothing on the server we actually talk to; matching by entry is the same row.
/// vmangos raises this code from exactly the condition the zeroing models —
/// `GetVendorItemCurrentCount(crItem) < totalCount` under `crItem->maxcount != 0`
/// (`Player.cpp:18508`).
pub(super) fn vendor_buy_failed(
vendor: u64,
item_entry: u32,
reason: u8,
merchant: &mut MerchantOpen,
errors: &mut MerchantErrors,
) {
debug!("net: buy failed (entry {item_entry}, reason {reason})");
if reason == benilla_protocol::messages::buy_result::ITEM_ALREADY_SOLD
&& merchant.vendor == Some(vendor)
{
merchant.sold_out(item_entry);
}
errors.0.push(MerchantRefusal::Buy(reason));
}
@@ -205,6 +205,17 @@ impl Shown {
}
}
/// Resolve a message key against the VM's own `GlobalStrings.lua` into a [`Shown`] — or `None`
/// when the key has no string there, which is the reference's own data-suppression face
/// (`0x4967bd`/`0x4967c5`) and the reason every raise site carries a *key* rather than text.
///
/// Lives here rather than in each window because every keyed raise site needs exactly this and
/// four of them had grown their own copy (decision 1821).
pub(crate) fn keyed_line(script: &UiScript, key: &'static str) -> Option<Shown> {
let text = script.lua().globals().get::<String>(key).ok()?;
(!text.is_empty()).then(|| Shown::keyed(key, text))
}
/// **The one sink** for a resolved message line: put it on the surface its [`MsgKind`] names, and
/// queue the sound its catalog row names.
///
+2 -2
View File
@@ -66,8 +66,8 @@ pub(crate) struct CooldownEvents;
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use errors::{
attack_actor_refusal, reagent_totem_refusal, show_messages, ui_error_text, CastErrors,
CastFail, MessageSink, MountErrors, Shown, UiError, UiErrorKeys, UiErrorTexts,
attack_actor_refusal, keyed_line, reagent_totem_refusal, show_messages, ui_error_text,
CastErrors, CastFail, MessageSink, MountErrors, Shown, UiError, UiErrorKeys, UiErrorTexts,
};
// `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the
// spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792).
+25 -22
View File
@@ -103,8 +103,13 @@ impl Plugin for UiBankPlugin {
}
/// The red error line for a `SMSG_BUY_BANK_SLOT_RESULT` code — the reference GlobalStrings'
/// `ERR_BANKSLOT_*` texts verbatim (decision 0604: the codes map 1:1 onto them). `OK` never
/// prints (vmangos doesn't send it; tolerated silently if a server ever does).
/// `ERR_BANKSLOT_*` texts verbatim (decision 0604: the codes map 1:1 onto them).
///
/// **The three ids are the reference's own table, and `None` is the reference's own silence**
/// (decision 1821): its handler at `0x5e3f8d` reads the `u32`, refuses `>= 3` outright
/// (`0x5e3f9c cmp eax,0x3; jae`) and otherwise indexes a 3-entry table at `0x80af14` holding
/// exactly `{0x100, 0x101, 0x102}`. So `OK` (3) is silent by the same bound as an unknown code —
/// there is no per-code fallback line to print, and printing one would be our invention.
fn bank_slot_error_key(result: u32) -> Option<&'static str> {
Some(match result {
bank_slot_result::FAILED_TOO_MANY => "ERR_BANKSLOT_FAILED_TOO_MANY", // 0x100
@@ -139,22 +144,12 @@ fn feed_bank(
let last = last.get(&script);
let last_banker = last_banker.get(&script);
// Purchase refusals go to the surface — and the voice — their message record names: the
// insufficient-funds row carries error-speech line `0x16` (decision 1815). A code with no key
// keeps its "(n)" line and stays silent, because there is no record behind it to sound.
// insufficient-funds row carries error-speech line `0x16` (decision 1815). A code outside the
// reference's three is silent, exactly as its `jae` makes it ([`bank_slot_error_key`]).
let lines: Vec<_> = errors
.0
.drain(..)
.filter_map(|result| match bank_slot_error_key(result) {
Some(key) => {
let text = script.lua().globals().get::<String>(key).ok()?;
(!text.is_empty()).then(|| crate::ui_action::Shown::keyed(key, text))
}
None if result == bank_slot_result::OK => None,
None => Some(crate::ui_action::Shown::unkeyed(
benilla_ui::messages::MsgKind::Error,
format!("Bank slot purchase failed ({result})."),
)),
})
.filter_map(|result| crate::ui_action::keyed_line(&script, bank_slot_error_key(result)?))
.collect();
crate::ui_action::show_messages(&mut script, &mut sink, "ui_bank", lines);
let store = self_q.iter().next();
@@ -220,7 +215,9 @@ mod tests {
use super::*;
/// The refusal-code → red-line map: the three vmangos failure codes print the reference
/// GlobalStrings verbatim, OK stays silent, an unknown code prints its number.
/// GlobalStrings verbatim, and everything else — `OK` and any unknown code alike — is silent,
/// which is the reference's own `cmp eax,0x3; jae` and not a policy of ours (1821). The ids
/// are asserted, not just the keys: those are what `0x80af14` actually holds.
#[test]
fn bank_slot_error_keys() {
assert_eq!(
@@ -236,14 +233,20 @@ mod tests {
Some("ERR_BANKSLOT_NOTBANKER")
);
assert_eq!(bank_slot_error_key(bank_slot_result::OK), None);
assert_eq!(bank_slot_error_key(99), None, "the feed's own \"(n)\" arm");
// Every key is a catalog row, and the insufficient-funds one is the spoken one (1815).
for key in [
"ERR_BANKSLOT_FAILED_TOO_MANY",
"ERR_BANKSLOT_INSUFFICIENT_FUNDS",
"ERR_BANKSLOT_NOTBANKER",
assert_eq!(
bank_slot_error_key(99),
None,
"past the reference's own bound"
);
// Every key is a catalog row, at the id `0x80af14` holds for its code; and the
// insufficient-funds one is the spoken one (1815).
for (key, id) in [
("ERR_BANKSLOT_FAILED_TOO_MANY", 0x100u16),
("ERR_BANKSLOT_INSUFFICIENT_FUNDS", 0x101),
("ERR_BANKSLOT_NOTBANKER", 0x102),
] {
let r = benilla_ui::messages::by_key(key).expect("catalog row");
assert_eq!(r.id, id, "{key}");
assert_eq!(r.kind, benilla_ui::messages::MsgKind::Error, "{key}");
}
assert_eq!(
+42 -25
View File
@@ -20,32 +20,39 @@
//! name (QUIT_TIMER "%d %s until exit"), which is why the reference gives QUIT an "Exit now" button
//! and CAMP none — a force-quit needs no server round trip, a force-logout does.
//!
//! **Pinned vs inferred.** The wire shape is verified twice over: vmangos's packet
//! (`WorldPackets::Misc::LogoutResponse` — `u32 reason, u8 instant`) and the real client's own
//! handler (wow-re `system/net/ledger.tsv` 0x5b4630, `Handle(0x4c) — {u32, u8}`). The 1.12
//! FrameXML fixes the UI half exactly: the CAMP/QUIT dialogs, their 20 s timeouts, and their
//! **All of it is now pinned** (decision 1821 — this module's three arms were the last INFERRED
//! join left in the leaving path). The wire shape is verified twice over: vmangos's packet
//! (`WorldPackets::Misc::LogoutResponse` — `u32 reason, u8 instant`) and the reference's own
//! handler (`0x5b4630`: `GetInt32` then `GetUInt8`, then a virtual `[vtable+0x54](reason,
//! instant)`, which is `[0x80a398+0x54]` = `0x5aaef0`). That override is this decision table byte
//! for byte:
//!
//! - `0x5aaef6 test eax,eax / jne 0x5aaf21` → **any** non-zero reason takes one arm:
//! `DisplayError(0x180)` = `ERR_LOGOUT_FAILED`, then clear the pending flag. There is no
//! per-reason string — combat, falling and GM-frozen all show the one line.
//! - `0x5aaf00 test eax,eax / jne` → `instant != 0` returns having done nothing; the completion
//! is already on its way.
//! - otherwise `setne cl` on `[this+0x1b1c]`, `add ecx,0x114`, `SignalEvent` → event **276
//! `PLAYER_CAMPING`** or **277 `PLAYER_QUITING`**, and `[this+0x1b1c]` is written from the
//! logout *request*'s own quit flag at `0x5ab053`. The reference remembers camp-vs-quit in one
//! local byte, which is exactly what [`LogoutState::quitting`] is.
//!
//! The 1.12 FrameXML fixes the rest: the CAMP/QUIT dialogs, their 20 s timeouts, and their
//! `PLAYER_CAMPING` / `PLAYER_QUITING` / `LOGOUT_CANCEL` drivers (UIParent.lua l.304-315, event
//! ids 276/277/278 in wow-re's `re/events/event-catalog.tsv`). What is **INFERRED** is the join
//! between them — that the client fires PLAYER_CAMPING off exactly this packet's `instant == 0` —
//! because the 0x4c handler's body isn't RE'd. It is the only wiring consistent with what the
//! reference client observably does (no dialog when you camp in an inn, a 20 s dialog in the
//! field, matching the server's own timer), and the falsification is cheap: a dialog that appears
//! on an inn logout, or none in the field, means the trigger is elsewhere. The refusal string is
//! inferred the same way (see [`ERR_LOGOUT_FAILED`]).
//! ids 276/277/278 in wow-re's `re/events/event-catalog.tsv`).
use benilla_ui::script::{ScriptValue, SessionRequest, UiScript};
use benilla_ui::script::{SessionRequest, UiScript};
use bevy::prelude::*;
use crate::net::{ClientCommand, LoggedOutMessage, NetCommands, SelfGuid};
use crate::ui_script::UiInput;
/// The reference's own refusal line (GlobalStrings.lua `ERR_LOGOUT_FAILED`), shown when the server
/// answers a non-zero reason. **INFERRED**: which of 1.12's several logout-failure strings the real
/// client picks per reason code isn't RE'd, and vmangos's three reasons (combat / falling / frozen)
/// have no distinct strings in the ERR_ table — this generic one covers all three, and is the line
/// vanilla is remembered for. `PLAYER_LOGOUT_FAILED_ERROR` ("…because you can't sit down right
/// now") is the other candidate and would be the correction if a capture ever shows it.
const ERR_LOGOUT_FAILED: &str = "You can't logout now.";
/// The reference's refusal line, as a **catalog key** rather than a literal — message id `0x180`,
/// the sole argument of the `DisplayError` at `0x5aaf26` (decision 1821). VERIFIED, superseding
/// this module's old inference: the reference picks this one string for every non-zero reason,
/// and `PLAYER_LOGOUT_FAILED_ERROR` — the other candidate the guess weighed — is never reached
/// from here. The text comes from the VM's own `GlobalStrings.lua`, so a locale rides for free.
const LOGOUT_FAILED_KEY: &str = "ERR_LOGOUT_FAILED";
/// What the wire told us to say next — drained by [`feed_logout`] into script events. A queue
/// rather than a flag: a cancel can land in the same frame as the response it cancels, and the UI
@@ -98,7 +105,9 @@ impl LogoutState {
});
}
/// `SMSG_LOGOUT_CANCEL_ACK` — the countdown is off.
/// `SMSG_LOGOUT_CANCEL_ACK` — the countdown is off. Its handler is the vtable slot next door
/// (`0x5b4680 call [eax+0x58]` → `0x5aaf60`), and all it does is fire event 278
/// `LOGOUT_CANCEL` off the same pending byte the refusal arm clears (decision 1821).
pub(crate) fn apply_cancelled(&mut self) {
self.quitting = false;
self.signals.push(LogoutSignal::Cancelled);
@@ -106,7 +115,15 @@ impl LogoutState {
}
/// Fire the camp/quit events the dialogs are driven by (GameMenuFrame.xml's `BenillaLogoutDriver`).
fn feed_logout(script: Option<NonSendMut<UiScript>>, mut logout: ResMut<LogoutState>) {
///
/// The refusal is shown one at a time through the message sink rather than collected, because the
/// signals are ordered — a cancel can land in the same frame as the response it cancels, and the
/// dialogs must see both in the order the wire delivered them.
fn feed_logout(
script: Option<NonSendMut<UiScript>>,
mut logout: ResMut<LogoutState>,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
return;
};
@@ -115,10 +132,10 @@ fn feed_logout(script: Option<NonSendMut<UiScript>>, mut logout: ResMut<LogoutSt
LogoutSignal::Camping => script.fire_event("PLAYER_CAMPING", Vec::new()),
LogoutSignal::Quiting => script.fire_event("PLAYER_QUITING", Vec::new()),
LogoutSignal::Cancelled => script.fire_event("LOGOUT_CANCEL", Vec::new()),
LogoutSignal::Refused => script.fire_event(
"UI_ERROR_MESSAGE",
vec![ScriptValue::Str(ERR_LOGOUT_FAILED.into())],
),
LogoutSignal::Refused => {
let line = crate::ui_action::keyed_line(&script, LOGOUT_FAILED_KEY);
crate::ui_action::show_messages(&mut script, &mut sink, "ui_logout", line);
}
}
}
}
+79 -57
View File
@@ -97,8 +97,8 @@ pub(crate) struct Stationery(pub(crate) benilla_formats::StationeryCatalog);
/// button). `MAIL_SEND_SUCCESS` fires additionally when `error == OK`.
pub(crate) struct MailSendAck {
pub(crate) ok: bool,
/// A pre-formatted red error line for a non-OK, non-equip send failure (else `None`).
pub(crate) refusal: Option<MailRefusal>,
/// The message key for a non-OK, non-equip send failure (else `None`).
pub(crate) refusal: Option<&'static str>,
}
/// The open mailbox, filled by the net bridge ([`crate::net::apply::mail`]) and read by
@@ -124,9 +124,10 @@ pub(crate) struct MailOpen {
last_list_query: Option<f64>,
/// SEND-action results the net bridge queued for the feed to fire ([`MailSendAck`]).
pub(crate) send_acks: Vec<MailSendAck>,
/// Refusals (a take/return/delete failure) the feed shows, resolved at the feed because a
/// keyed one reads its text out of the VM ([`MailRefusal`]).
pub(crate) errors: Vec<MailRefusal>,
/// Refusals (a take/return/delete failure) the feed shows, as message keys — resolved at the
/// feed, which is where the VM's `GlobalStrings` can be read
/// ([`crate::ui_action::keyed_line`]).
pub(crate) errors: Vec<&'static str>,
}
impl MailOpen {
@@ -239,56 +240,36 @@ fn send_query_next_mail_time_on_enter(
}
}
/// One queued mail refusal, on its way to the red line.
/// The message a `SMSG_SEND_MAIL_RESULT` code shows — **always** a catalog key, which is the whole
/// correction decision 1821 brought here (superseding 0544's hand-written pair).
///
/// Two arms because 1.12's own vocabulary has two: most `MailResponseResult` codes name a
/// **message-catalog key**, whose row decides the text (from the player's own `GlobalStrings`),
/// the surface, and — for `ERR_NOT_ENOUGH_MONEY`, line `0x28` — the *voice* (decision 1815). Two
/// codes have no 1.12 string at all, and those keep the best-effort English literal they have
/// always had: no row, so no sound, which is the honest shape rather than a borrowed one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum MailRefusal {
Key(&'static str),
Text(String),
}
/// The refusal a `SMSG_SEND_MAIL_RESULT` error shows (vmangos `MailResponseResult`); keys
/// cross-checked against the reference `GlobalStrings.lua` — every one of them resolves there to
/// exactly the string this used to inline (decision 0544, now read at runtime instead).
pub(crate) fn mail_refusal(error: u32) -> MailRefusal {
/// The reference's handler `0x4ad050` indexes `[0x4ad1a0 + error]` into a nine-entry jump table at
/// `0x4ad17c`, every arm a `push <id>; call CGGameUI::DisplayError`. This module used to claim
/// *"No 1.12 GlobalString for either"* about codes 5 and 14 and inline near-miss English for them;
/// both are real rows, and their real strings differ from what we were showing. And the arm this
/// module used to spell `"Mail failed (n)."` is the reference's own `ja 0x4ad147` — every code
/// from 6 to 13 and everything past 15 lands on `ERR_MAIL_DATABASE_ERROR` with them.
///
/// `OK` and `EQUIP_ERROR` never reach here: the first is not a refusal (it has
/// [`MAIL_SENT_KEY`] instead) and the second resolves through the equip-error table
/// (`0x4ad12e` → `0x622630`), which is [`crate::ui_items`]' job.
pub(crate) fn mail_refusal(error: u32) -> &'static str {
match error {
mail_error::CANNOT_SEND_TO_SELF => MailRefusal::Key("ERR_MAIL_TO_SELF"), // 0x164
mail_error::NOT_ENOUGH_MONEY => MailRefusal::Key("ERR_NOT_ENOUGH_MONEY"), // 0x25
mail_error::RECIPIENT_NOT_FOUND => MailRefusal::Key("ERR_MAIL_TARGET_NOT_FOUND"), // 0x165
mail_error::INTERNAL_ERROR => MailRefusal::Key("ERR_MAIL_DATABASE_ERROR"), // 0x166
mail_error::TOO_MANY_ATTACHMENTS => MailRefusal::Key("ERR_MAIL_REACHED_CAP"), // 0x1c4
// No 1.12 GlobalString for either — flagged literals, as before.
mail_error::NOT_YOUR_TEAM => {
MailRefusal::Text("That player is not part of your alliance.".into())
}
mail_error::TRIAL_ACCOUNT => {
MailRefusal::Text("Trial accounts cannot perform that action.".into())
}
other => MailRefusal::Text(format!("Mail failed ({other}).")),
mail_error::CANNOT_SEND_TO_SELF => "ERR_MAIL_TO_SELF", // 0x164
mail_error::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25 — speaks, line 0x28
mail_error::RECIPIENT_NOT_FOUND => "ERR_MAIL_TARGET_NOT_FOUND", // 0x165
mail_error::NOT_YOUR_TEAM => "ERR_PLAYER_WRONG_FACTION", // 0xff
mail_error::TRIAL_ACCOUNT => "ERR_RESTRICTED_ACCOUNT", // 0x1be
mail_error::TOO_MANY_ATTACHMENTS => "ERR_MAIL_REACHED_CAP", // 0x1c4
// INTERNAL_ERROR (6) and every other code, by the same `0x4ad147`.
_ => "ERR_MAIL_DATABASE_ERROR", // 0x166
}
}
impl MailRefusal {
/// Resolve to a displayable line, reading a keyed one out of the VM's own `GlobalStrings`.
/// `None` for a key whose string is absent or empty — the reference's data-suppression face.
fn shown(self, get: &dyn Fn(&str) -> Option<String>) -> Option<crate::ui_action::Shown> {
match self {
Self::Key(key) => {
let text = get(key)?;
(!text.is_empty()).then(|| crate::ui_action::Shown::keyed(key, text))
}
Self::Text(text) => Some(crate::ui_action::Shown::unkeyed(
benilla_ui::messages::MsgKind::Error,
text,
)),
}
}
}
/// The line a **successful** `SEND` shows: `ERR_MAIL_SENT` — id `0x167`, and a `kind 1` row, so it
/// is the yellow info line and not the red one. `0x4ad0b1` reaches it before the form reset
/// (`0x4acdc0`), gated on the action being `SEND`; a successful take/return/delete says nothing.
pub(crate) const MAIL_SENT_KEY: &str = "ERR_MAIL_SENT";
mod invoice;
@@ -532,21 +513,25 @@ fn feed_mail(
// Take/return/delete failures go to the surface — and the voice — their message record names.
let refusals: Vec<_> = std::mem::take(&mut mail.errors)
.into_iter()
.filter_map(|r| r.shown(&|key| script.lua().globals().get::<String>(key).ok()))
.filter_map(|key| crate::ui_action::keyed_line(&script, key))
.collect();
crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", refusals);
// SEND acks: MAIL_FAILED on EVERY send result (unblocks the button — wow-re §5), plus
// MAIL_SEND_SUCCESS on OK (resets the form) or the red error line on a failure.
// MAIL_SEND_SUCCESS on OK (resets the form) or the refusal's own line on a failure. The
// success also SAYS so — `ERR_MAIL_SENT`, the yellow info line `0x4ad0b1` shows before it
// resets the form (decision 1821).
for ack in std::mem::take(&mut mail.send_acks) {
script.fire_event("MAIL_FAILED", vec![]);
let key = if ack.ok {
Some(MAIL_SENT_KEY)
} else {
ack.refusal
};
let line = key.and_then(|k| crate::ui_action::keyed_line(&script, k));
crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", line);
if ack.ok {
script.fire_event("MAIL_SEND_SUCCESS", vec![]);
script.clear_send_mail_item();
} else if let Some(line) = ack
.refusal
.and_then(|r| r.shown(&|key| script.lua().globals().get::<String>(key).ok()))
{
crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", [line]);
}
}
@@ -772,6 +757,43 @@ mod tests {
use super::*;
use benilla_protocol::messages::MailAttachment;
/// **The mail-result table, welded to the message ids it was read from** (decision 1821) —
/// the reference's jump table at `0x4ad17c`, reached through the byte index at `0x4ad1a0`.
/// The id, not the key, is what was disassembled, so the id is what this asserts.
#[test]
fn the_mail_result_table_is_the_references_own() {
let id = |key: &str| {
benilla_ui::messages::by_key(key)
.unwrap_or_else(|| panic!("{key} is not a catalog row"))
.id
};
for (code, want) in [
(mail_error::CANNOT_SEND_TO_SELF, 0x164u16),
(mail_error::NOT_ENOUGH_MONEY, 0x25),
(mail_error::RECIPIENT_NOT_FOUND, 0x165),
(mail_error::NOT_YOUR_TEAM, 0xff),
(mail_error::INTERNAL_ERROR, 0x166),
(mail_error::TRIAL_ACCOUNT, 0x1be),
(mail_error::TOO_MANY_ATTACHMENTS, 0x1c4),
] {
assert_eq!(id(mail_refusal(code)), want, "mail error {code}");
}
// `ja 0x4ad147` and the index table's `08` run: 7..13 and everything past 15 share the
// database-error line, and there is no "(n)" arm for them to take.
for code in [7, 8, 13, 16, 99, u32::MAX] {
assert_eq!(id(mail_refusal(code)), 0x166, "mail error {code}");
}
// A success SAYS so — and on the YELLOW line, which is the half a red-only path would
// have got wrong.
assert_eq!(id(MAIL_SENT_KEY), 0x167);
assert_eq!(
benilla_ui::messages::by_key(MAIL_SENT_KEY)
.expect("row")
.kind,
benilla_ui::messages::MsgKind::Info
);
}
/// The no-subject macro context these row tests run under: none of them exercises a `$` token,
/// and a subject-less context leaves plain text untouched (the expander only rewrites at a `$`).
/// The macro path's own behaviour is covered in [`crate::npc_text`]'s tests.
+164 -36
View File
@@ -92,6 +92,17 @@ impl MerchantOpen {
}
}
/// Mark every row of an item **sold out** — the `SMSG_BUY_FAILED` `ITEM_ALREADY_SOLD` arm
/// (`0x5dcdbf mov DWORD PTR [esi],0x0`), so a limited-stock row that just refused the click
/// stops showing the count that tempted it. Unconditional, as the reference's write is: only a
/// limited row can raise this refusal, and the reference does not break on the first match
/// either (`0x5dcdcd` walks all 128 cache rows).
pub(crate) fn sold_out(&mut self, entry: u32) {
for it in self.items.iter_mut().filter(|it| it.entry == entry) {
it.current_count = 0;
}
}
/// Close the open window (a client-side close). Keeps nothing — a re-open re-lists.
pub(crate) fn clear(&mut self) {
self.vendor = None;
@@ -143,36 +154,53 @@ impl Plugin for UiMerchantPlugin {
}
}
/// The client's message string for a `BuyResult` refusal (`SMSG_BUY_FAILED`); values from
/// [`buy_result`] (vmangos `ItemDefines.h`). Unknown reasons print their code.
fn buy_error_text(reason: u8) -> String {
match reason {
buy_result::CANT_FIND_ITEM => "That item does not exist.".into(),
buy_result::ITEM_ALREADY_SOLD => "That item has already been sold.".into(),
buy_result::NOT_ENOUGH_MONEY => "You don't have enough money.".into(),
buy_result::SELLER_DONT_LIKE_YOU => "The vendor doesn't like you.".into(),
buy_result::DISTANCE_TOO_FAR => "You are too far away.".into(),
buy_result::ITEM_SOLD_OUT => "The vendor is sold out of that item.".into(),
buy_result::CANT_CARRY_MORE => "You can't carry any more of those items.".into(),
buy_result::RANK_REQUIRE => "You don't have the required rank for that item.".into(),
buy_result::REPUTATION_REQUIRE => {
"You don't have the required reputation for that item.".into()
}
other => format!("Purchase failed ({other})."),
}
/// The one `BuyResult` code the reference deliberately says **nothing** for. It has no vmangos
/// name because vmangos never sends it: `0x5dcde7`'s jump table sends code 6 to the handler's own
/// `ret`, past the `DisplayError` its neighbours land on.
const BUY_SILENT: u8 = 6;
/// The GlobalStrings key a `BuyResult` refusal (`SMSG_BUY_FAILED`) shows — the reference's switch
/// at `0x5dcdd8`, each arm a `CGGameUI::DisplayError(msgId)` call, read off the binary for
/// decision 1821. `None` is silence the reference chose, not a hole in the table.
///
/// **The `default` arm is a real message, not an invented fallback**: every code outside the
/// table — 0, 3, 9, 10 and everything from 13 up — shows `ERR_ITEM_NOT_FOUND` (`0x5dce81`, the
/// `ja` target of `cmp eax,0xc`).
fn buy_error_key(reason: u8) -> Option<&'static str> {
Some(match reason {
// One arm (`0x5dcdee`) for both: vmangos calls 1 ALREADY_SOLD and 7 SOLD_OUT, and the
// reference tells them apart only by code 1 also zeroing the row
// ([`crate::net::apply::npc::vendor_buy_failed`]).
buy_result::ITEM_ALREADY_SOLD | buy_result::ITEM_SOLD_OUT => "ERR_VENDOR_SOLD_OUT", // 0x23
buy_result::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25 — speaks, line 0x28
buy_result::SELLER_DONT_LIKE_YOU => "ERR_VENDOR_HATES_YOU", // 0x22
buy_result::DISTANCE_TOO_FAR => "ERR_VENDOR_TOO_FAR", // 0x24
BUY_SILENT => return None,
buy_result::CANT_CARRY_MORE => "ERR_ITEM_MAX_COUNT", // 0x12 — speaks, line 0x1e
buy_result::RANK_REQUIRE => "ERR_CANT_EQUIP_RANK", // 0x05
buy_result::REPUTATION_REQUIRE => "ERR_CANT_EQUIP_REPUTATION", // 0x06
_ => "ERR_ITEM_NOT_FOUND", // 0x17 — the switch's own default
})
}
/// The client's message string for a `SellResult` refusal (`SMSG_SELL_ITEM`'s error path); values
/// from [`sell_result`] (vmangos `ItemDefines.h`).
fn sell_error_text(reason: u8) -> String {
match reason {
sell_result::CANT_FIND_ITEM => "That item does not exist.".into(),
sell_result::CANT_SELL_ITEM => "You can't sell that item.".into(),
sell_result::CANT_FIND_VENDOR => "That vendor can't be found.".into(),
sell_result::YOU_DONT_OWN_THAT_ITEM => "You don't own that item.".into(),
sell_result::ONLY_EMPTY_BAG => "You can only sell an empty bag.".into(),
other => format!("Sale failed ({other})."),
}
/// The GlobalStrings key a `SellResult` refusal (`SMSG_SELL_ITEM`'s error path) shows — the
/// reference's switch at `0x5dd22c` (decision 1821).
///
/// **Silence is the default here**, the opposite of [`buy_error_key`]: code 0 returns before the
/// switch (`0x5dd21e`), and 5 and everything from 7 up jump past the `DisplayError` to the
/// handler's tail. vmangos's own header agrees line for line — its comment on `SELL_ERR_UNK = 5`
/// is *"nothing appears…"*, and its comments on 2 and 3 (*"merchant doesn't like that item"* /
/// *"merchant doesn't like you"*) name the two strings the reference picks, which its `CANT_SELL`
/// / `CANT_FIND_VENDOR` identifiers do not.
fn sell_error_key(reason: u8) -> Option<&'static str> {
Some(match reason {
sell_result::CANT_FIND_ITEM => "ERR_ITEM_NOT_FOUND", // 0x17
sell_result::CANT_SELL_ITEM => "ERR_VENDOR_NOT_INTERESTED", // 0x21
sell_result::CANT_FIND_VENDOR => "ERR_VENDOR_HATES_YOU", // 0x22
sell_result::YOU_DONT_OWN_THAT_ITEM => "ERR_NOT_OWNER", // 0x1b
sell_result::ONLY_EMPTY_BAG => "ERR_DESTROY_NONEMPTY_BAG", // 0x0b
_ => return None,
})
}
/// Resolve one wire [`VendorItem`] into the Lua-facing [`MerchantItem`]: the icon comes straight from
@@ -421,6 +449,7 @@ fn feed_merchant(
mut last_money: Local<crate::ui_script::VmMemo<Option<u64>>>,
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
mut last_vendor: Local<crate::ui_script::VmMemo<Option<u64>>>,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
return;
@@ -429,14 +458,21 @@ fn feed_merchant(
let last_money = last_money.get(&script);
let last_name = last_name.get(&script);
let last_vendor = last_vendor.get(&script);
// Refusals surface as the client's red error line (the equip/cast path's exact shape).
for refusal in errors.0.drain(..) {
let text = match refusal {
MerchantRefusal::Buy(r) => buy_error_text(r),
MerchantRefusal::Sell(r) => sell_error_text(r),
};
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
// Refusals go to the surface — and the voice — their message record names (decision 1815):
// the vendor's "not enough money" carries error-speech line 0x28, "you can't carry any more"
// line 0x1e. A code the reference says nothing for resolves to no key and reaches nothing.
let refusals: Vec<_> = errors
.0
.drain(..)
.filter_map(|refusal| {
let key = match refusal {
MerchantRefusal::Buy(r) => buy_error_key(r)?,
MerchantRefusal::Sell(r) => sell_error_key(r)?,
};
crate::ui_action::keyed_line(&script, key)
})
.collect();
crate::ui_action::show_messages(&mut script, &mut sink, "ui_merchant", refusals);
// The purse: push it only when it changes (u64 copper straight from PLAYER_FIELD_COINAGE),
// and fire the real client's PLAYER_MONEY event with it — the money displays (bag + merchant
// purses) repaint on the event rather than riding some window-content repaint that happens to
@@ -659,6 +695,98 @@ mod tests {
}
}
/// **The two refusal tables, welded to the message ids they were read from** (decision 1821).
/// Asserting the key alone would let a plausible-looking rename through; asserting the id it
/// resolves to is asserting the `push <id>; call 0x496720` that was actually disassembled.
#[test]
fn the_refusal_tables_are_the_references_own() {
let id = |key: &str| {
benilla_ui::messages::by_key(key)
.unwrap_or_else(|| panic!("{key} is not a catalog row"))
.id
};
// `SMSG_BUY_FAILED` — the switch at `0x5dcdd8`. Note 1 and 7 share `0x23`, 6 is silent,
// and the arms outside the table fall to `0x17` rather than to nothing.
for (code, want) in [
(0u8, Some(0x17u16)),
(1, Some(0x23)),
(2, Some(0x25)),
(3, Some(0x17)),
(4, Some(0x22)),
(5, Some(0x24)),
(6, None),
(7, Some(0x23)),
(8, Some(0x12)),
(9, Some(0x17)),
(10, Some(0x17)),
(11, Some(0x05)),
(12, Some(0x06)),
(13, Some(0x17)),
(255, Some(0x17)),
] {
assert_eq!(buy_error_key(code).map(id), want, "buy code {code}");
}
// `SMSG_SELL_ITEM` — the switch at `0x5dd22c`, where the default is silence.
for (code, want) in [
(0u8, None),
(1, Some(0x17u16)),
(2, Some(0x21)),
(3, Some(0x22)),
(4, Some(0x1b)),
(5, None),
(6, Some(0x0b)),
(7, None),
(255, None),
] {
assert_eq!(sell_error_key(code).map(id), want, "sell code {code}");
}
}
/// The two vendor refusals that **speak** — the reason the keys had to replace the hand-written
/// English at all (decision 1815's join). Everything else in the two tables is a silent row.
#[test]
fn the_purse_refusals_carry_their_voice_lines() {
let tag = |key: &str| benilla_ui::messages::by_key(key).expect("row").type_tag;
assert_eq!(tag("ERR_NOT_ENOUGH_MONEY"), 0x28);
assert_eq!(tag("ERR_ITEM_MAX_COUNT"), 0x1e);
for key in [
"ERR_VENDOR_SOLD_OUT",
"ERR_VENDOR_HATES_YOU",
"ERR_VENDOR_TOO_FAR",
"ERR_ITEM_NOT_FOUND",
"ERR_VENDOR_NOT_INTERESTED",
"ERR_NOT_OWNER",
"ERR_DESTROY_NONEMPTY_BAG",
"ERR_CANT_EQUIP_RANK",
"ERR_CANT_EQUIP_REPUTATION",
] {
assert_eq!(tag(key), 0x44, "{key}");
}
}
/// `ITEM_ALREADY_SOLD` zeroes the row it refused, and only that row — the reference's own
/// cache write (`0x5dcdbf`), keyed by entry because vmangos puts the entry in that field.
#[test]
fn a_sold_out_refusal_zeroes_only_that_rows_count() {
let mut open = MerchantOpen::default();
open.open(7, vec![row(11, 1, 3), row(22, 2, 5), row(11, 3, 2)]);
open.sold_out(11);
assert_eq!(open.items[0].current_count, 0);
assert_eq!(
open.items[1].current_count, 5,
"a different entry is untouched"
);
assert_eq!(
open.items[2].current_count, 0,
"the reference does not stop at the first match"
);
open.sold_out(999);
assert_eq!(
open.items[1].current_count, 5,
"an entry we do not stock is a no-op"
);
}
#[test]
fn entry_at_maps_the_one_based_row() {
let mut open = MerchantOpen::default();
@@ -603,8 +603,12 @@ fn get_chat_window_info_shown_matches_the_shipped_frames() {
for i in 1..=7 {
let agrees: bool = s
.eval(&format!(
// Both sides are normalised to a boolean before comparing: `IsShown` answers the
// NUMBER 1 or nil (1830), so comparing it straight against `shown ~= nil` compares
// a number with a boolean and is false for every window. This test is about the two
// AGREEING, not about either one's shape.
"local _, _, _, _, _, _, shown = GetChatWindowInfo({i})\n\
return (shown ~= nil) == ChatFrame{i}:IsShown()"
return (shown ~= nil) == (ChatFrame{i}:IsShown() ~= nil)"
))
.unwrap();
assert!(
@@ -743,7 +743,9 @@ fn the_rank_editor_loads_its_flags_and_arms_accept_on_an_edit() {
s.run("GuildControlPopupFrameCheckbox5:Click()").unwrap();
assert_eq!(
calls(&s),
"GuildControlSetRankFlag:5:true",
// The Lua hands `this:GetChecked()` straight through, and in 1.12 that is the NUMBER 1
// (1830) — so 1 is what the reference's own call carries too.
"GuildControlSetRankFlag:5:1",
"the checkbox's own ID is what reaches the engine"
);
assert!(enabled(&s, "GuildControlPopupAcceptButton"));
@@ -1432,20 +1432,6 @@ mod tests {
update; our `inventory_alerts` snapshot is recomputed on every inventory push \
instead, so the recompute exists and only the Lua verb that forces one does not.",
),
(
"QuestTimerFrame.xml",
"QuestLog_SetSelection",
"a FrameXML function, declared in stock `QuestLogFrame.lua:308` — which we do not \
source, because our own `QuestLogFrame.xml` is that window. Clicking a quest \
TIMER raises (`QuestTimerFrame.lua:43`). It arrives with the quest log's own \
migration and not before.",
),
(
"QuestTimerFrame.xml",
"QuestLog_Update",
"the twin of the line above, `QuestLogFrame.lua:107`, called on the next line \
(`QuestTimerFrame.lua:44`).",
),
];
let toc = &super::super::addons::Addon::builtin().toc.files;
+39 -6
View File
@@ -19,10 +19,30 @@ fn taxi_script() -> UiScript {
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // TaxiNodeOnButtonEnter's tooltip + SetTooltipMoney
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml"); // BenillaErrorsFrame_AddMessage — DrawOneHopLines' refusal
load_xml(&s, "TaxiFrame.xml");
// Three the reference's own TaxiFrame leans on that our transcription did not:
// · GlobalStrings — `ERR_TAXINOPATHS` is a GlobalString, and `AddMessage(nil)` draws an
// empty line rather than raising, so its absence is silent.
// · UIPanelTemplates (.lua then .xml) — `TaxiCloseButton` inherits `UIPanelCloseButton`,
// which lives there and NOT in our UiPanels.xml. Without it the close button loads as a
// bare Button with no handler and a click does nothing at all.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\TaxiFrame.xml");
s
}
/// Seat the flight master as the `"npc"` unit. Stock `TaxiFrame_OnEvent` fills its label from
/// `UnitName("npc")` (TaxiFrame.lua:27), where our deleted transcription read the `TAXIMAP_OPENED`
/// argument — so a fixture that only fires the event leaves the reference's label blank.
fn seat_flight_master(s: &mut UiScript, name: &str) {
let npc = benilla_ui::script::UnitState {
name: Some(name.into()),
..Default::default()
};
s.set_unit("npc", Some(npc));
}
/// A two-node snapshot: Stormwind (Current) and the verified Sentinel Hill hop (Reachable, 110
/// copper, one route segment) — [`crate::ui_taxi::build_nodes`]'s exact real-data shape.
fn menu() -> TaxiUiState {
@@ -59,6 +79,7 @@ fn shipped_taxi_frame_drives_end_to_end() {
assert!(!s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
s.set_taxi(Some(menu()));
seat_flight_master(&mut s, "Dungar Longdrink");
s.fire_event(
"TAXIMAP_OPENED",
vec![ScriptValue::Str("Dungar Longdrink".into())],
@@ -67,15 +88,22 @@ fn shipped_taxi_frame_drives_end_to_end() {
assert!(s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return TaxiNameText:GetText()").unwrap(),
// The reference names the flight-master label `TaxiMerchant` and fills it from
// `UnitName("npc")` (TaxiFrame.lua:27) — NOT from the event's argument, which is what our
// transcription's `TaxiNameText` read. So the fixture seats the npc unit above.
s.eval::<String>("return TaxiMerchant:GetText()").unwrap(),
"Dungar Longdrink"
);
// The two pushed nodes show; the pool's remainder (slot 3 onward) stays hidden.
// The two pushed nodes show, and there is no third button AT ALL — the reference builds its
// node buttons on demand, one `CreateFrame("Button", "TaxiButton"..i, TaxiRouteMap,
// "TaxiButtonTemplate")` per node (TaxiFrame.lua:39), where our transcription pre-made a fixed
// pool and hid the tail. Same thing on screen, a different mechanism behind it — so the
// expectation moves from "hidden" to "absent".
assert!(s.eval::<bool>("return TaxiButton1:IsVisible()").unwrap());
assert!(s.eval::<bool>("return TaxiButton2:IsVisible()").unwrap());
assert!(!s.eval::<bool>("return TaxiButton3:IsVisible()").unwrap());
assert!(!s.eval::<bool>("return TaxiButton50:IsVisible()").unwrap());
assert!(s.eval::<bool>("return TaxiButton3 == nil").unwrap());
assert!(s.eval::<bool>("return TaxiButton50 == nil").unwrap());
// A click on node 2 (Sentinel Hill) drains through TakeTaxiNode.
s.resolve();
@@ -113,6 +141,7 @@ fn no_single_hop_destination_posts_the_error_and_closes() {
routes: vec![],
}],
}));
seat_flight_master(&mut s, "Dungar Longdrink");
s.fire_event(
"TAXIMAP_OPENED",
vec![ScriptValue::Str("Dungar Longdrink".into())],
@@ -142,13 +171,17 @@ fn no_single_hop_destination_posts_the_error_and_closes() {
fn close_button_queues_the_intent_and_hides() {
let mut s = taxi_script();
s.set_taxi(Some(menu()));
seat_flight_master(&mut s, "Dungar Longdrink");
s.fire_event(
"TAXIMAP_OPENED",
vec![ScriptValue::Str("Dungar Longdrink".into())],
);
assert!(s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
s.run("BenillaTaxiCloseButton_OnClick()").unwrap();
// The reference's close button is a plain `UIPanelCloseButton` (TaxiFrame.xml:133) — it has no
// handler of its own, so the click goes through the template's, which hides the parent panel.
// Ours carried a named `BenillaTaxiCloseButton_OnClick`; that went with the file.
s.run("TaxiCloseButton:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.take_taxi_close());
assert!(!s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
+59
View File
@@ -81,6 +81,65 @@ fn every_error_key_in_the_source_is_a_catalog_row() {
);
}
/// **The claim this file's own doc has always made, finally checked** (decision 1821): a key the
/// source names must not only be a catalog row, it must **resolve to text**.
///
/// The two are not the same, and the difference is the failure this file exists to catch. A row's
/// `key` is a `GlobalStrings.lua` *lookup*, and the shipped file has no entry for a good many of
/// them — that absence is the reference's own data-suppression face, and a raise site that picks
/// one of those rows is silent for good. The walk above would pass it.
///
/// Worth closing now because 1821 moved four more windows' refusals onto keys (the vendor's two
/// tables, the banker's, the mailbox's), where a superseded or mistyped key costs a whole window's
/// worth of lines rather than one toast. Skips without client data.
#[test]
fn every_error_key_in_the_source_resolves_to_real_text() {
/// Keys whose row the shipped `GlobalStrings.lua` has **no string for**, so the reference
/// itself shows nothing when it raises them. Both are documented where they are raised —
/// `ui_items::equip_error` (errorId 362) and `ui_action::cast_fail` (the pet-happiness arm).
/// A third entry here is a defect until someone proves otherwise.
const SILENT_IN_5875: &[&str] = &["ERR_CANT_BE_DISENCHANTED", "ERR_NOT_HAPPY_ENOUGH"];
let data = match benilla_formats::wow_data() {
Some(d) => d,
None => return,
};
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let vm = benilla_ui::script::UiScript::new().expect("VM");
vm.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let mut sources = Vec::new();
walk(Path::new("src"), &mut sources);
walk(Path::new("../benilla-ui/src"), &mut sources);
let keys: BTreeSet<String> = sources.iter().flat_map(|s| err_keys(s)).collect();
let mut resolved = 0;
let silent: Vec<&String> = keys
.iter()
.filter(|k| !NOT_A_MESSAGE.contains(&k.as_str()))
.filter(|k| !SILENT_IN_5875.contains(&k.as_str()))
.filter(|k| {
let text: String = vm.lua().globals().get(k.as_str()).unwrap_or_default();
resolved += usize::from(!text.is_empty());
text.is_empty()
})
.collect();
assert!(
silent.is_empty(),
"these keys are catalog rows but resolve to NOTHING in the shipped GlobalStrings.lua, so \
every line raised through them is invisible: {silent:?}"
);
// A floor, not a ledger: the point is that the VM really loaded the file, so an empty `vm`
// cannot make the sweep above vacuously green.
assert!(
resolved > 150,
"only {resolved} keys resolved — did GlobalStrings load?"
);
}
/// **The other half of the same claim, on real data: a key that carries a VOICE line can actually
/// be spoken** (decision 1815).
///
+6 -2
View File
@@ -345,9 +345,13 @@ impl MaterialExtension for WowModelExt {
// The nudge now lives in `wow_model.wgsl`'s vertex stage, an exact relative scale of clip z
// driven by `sun_scale.y` (uniform DATA, no pipeline axis) — same one-ULP-per-index
// semantics, byte-verified intent unchanged (wow-5875-re wmo-batch-blend-depth-state.md).
if key.bind_group_data.fade {
if key.bind_group_data.fade && !key.bind_group_data.sky_depth {
// The distance-fade blend twin needs depth-write ON so near geometry occludes far within the
// same fading model — force it regardless of the per-flag rule above.
// same fading model — force it regardless of the per-flag rule above. EXCEPT on the
// WMO-skybox lane: its twin exists for the 4-second crossfade, where every fragment
// forces the one far depth — there is no within-model occlusion to preserve, and the
// sky's own law is depth-write off, always (the fragment leaves the z-buffer at its
// clear value so the world and the forced-far glare quads order by depth alone).
if let Some(ds) = descriptor.depth_stencil.as_mut() {
ds.depth_write_enabled = true;
}
+1 -1
View File
@@ -830,7 +830,7 @@ mod loader_tests {
return XmlBtn:GetText() == "Push Me"
and XmlBtn:GetNormalTexture() ~= nil
and XmlBtn:GetPushedTexture() ~= nil
and XmlCheck:GetChecked() == true
and XmlCheck:GetChecked() == 1
"#,
)
.unwrap();
+4 -4
View File
@@ -68,11 +68,11 @@ pub struct MessageRecord {
pub key: &'static str,
/// Where it is shown.
pub kind: MsgKind,
/// The sound cue played with it, or `None` for the 435 silent rows. Carried as data;
/// benilla does not wire message cues yet.
/// The sound cue played with it, or `None` for the 435 silent rows. Read at the display, by
/// `benilla_app::sound::message` (decision 1815).
pub sound: Option<&'static str>,
/// `+0x0c`. `0x44` means "play [`Self::sound`]"; the other 56 rows carry an error-speech id
/// instead. Unmodelled, recorded.
/// `+0x0c`. `0x44` means "play [`Self::sound`]"; the other 56 rows carry an error-speech line
/// id instead, spoken in the player's own race and gender voice (decision 1815).
pub type_tag: u8,
/// The chat type handed to the composer, meaningful for [`MsgKind::Chat`] rows only. `10`
/// (`CHAT_MSG_SYSTEM`) for all but three — the skill-up trio `ERR_PROFICIENCY_GAINED_S`,
@@ -163,6 +163,36 @@ pub(crate) fn optional_string(lua: &Lua, v: &Value) -> Option<String> {
/// *strings* `"0"`/`"1"`, and a re-implementation wired to `lua_toboolean` inverts every
/// string-valued option in the game. Equally, `"0.5"` and `"-1"` are decided by their first byte
/// alone — false and `default` — never by their numeric value.
/// A widget **predicate's return**: the NUMBER `1` for true, `nil` for false — never a Lua boolean.
///
/// The return-side counterpart to [`bool_or_default`], and the same class of fact: 1.12 widget
/// bindings do not push booleans. `lua_pushboolean 0x6f39f0` has seven call sites in the whole
/// image and not one of them is inside a widget registrar body — every predicate goes through
/// `lua_pushnumber`/`lua_pushnil`. Decision 1830.
///
/// **Truthiness hides the difference and direct comparison inverts it**, which is why this survived
/// so long. `if frame:IsVisible()` reads the same either way; `if frame:IsVisible() == nil` does
/// not — under a boolean a hidden frame answers `false`, and `false == nil` is FALSE, so the caller
/// concludes the frame is visible exactly when it is not. The 1.12 addon corpus has 21 such direct
/// comparisons (`IsVisible() == nil` ×9, `GetChecked() == 1` ×6, `IsVisible() ~= nil` ×4, and one
/// each of `~= 1` / `== 1`) across Questie, AtlasQuest, CT_BagMod, MikScrollingBattleText's options
/// and `_dl`.
///
/// The reference proves its own shape without needing the bytes: stock `UIOptionsFrame.xml:310`
/// saves a checkbox as `SHOW_BUFF_DURATIONS = tostring(this:GetChecked())` and stock
/// `BuffFrame.lua:71` reads it back as `== "1"`. That round-trip only closes if `GetChecked`
/// returns the number 1 — `tostring(true)` is `"true"`, and buff timers would never appear.
///
/// Adopting it is strictly safer than what it replaces: every `if x` and `not x` site reads
/// identically, and only the direct comparisons change — from wrong to right.
pub(crate) fn predicate(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
pub(crate) fn bool_or_default(v: Option<&Value>, default: bool) -> bool {
let Some(v) = v else {
return default; // LUA_TNONE
+11 -1
View File
@@ -816,8 +816,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
c.set(
// GetChecked() -> the NUMBER 1 or nil, never a boolean (1830). Its neighbour
// `IsEnabled` two entries up was corrected for the same reason under 1719, whose comment
// records that a boolean here killed pfUI's widget module outright; this is that call
// finished. The reference proves the shape in its own source rather than only at the
// bytes: stock `UIOptionsFrame.xml:310` saves a checkbox as
// `tostring(this:GetChecked())` and stock `BuffFrame.lua:71` reads it back as `== "1"`,
// a round-trip that only closes on the number.
"GetChecked",
lua.create_function(|lua, this: Table| with_button(lua, &this, |bs| bs.checked))?,
lua.create_function(|lua, this: Table| {
let checked = with_button(lua, &this, |bs| bs.checked)?;
Ok(crate::script::binding_abi::predicate(checked))
})?,
)?;
lua.set_named_registry_value(REG_CHECKBUTTON_METHODS, c)?;
+18
View File
@@ -203,6 +203,24 @@ pub fn frame_kind_from_tag(s: &str) -> Option<FrameKind> {
fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
Some(match enum_token(s).as_str() {
"FRAME" => FrameKind::Frame,
// `TaxiRouteFrame` — a registered `CreateFrame` type that is a `CSimpleFrame` and NOTHING
// else, so it maps to `Frame` rather than earning a kind (decision 1828; wow-re
// `ui/scratch/taxiroute-widget-type.md`). Factory `0x495ba0` allocates `0x314`, the same
// size the plain-`<Frame>` factory `0x6eec10` allocates for the same base ctor `0x769090`,
// and the ctor `0x506950` adds no field. Its vtables are the base's length exactly (36 + 11
// slots), so it declares no new virtual; it overrides four slots, of which two are lifetime
// plumbing and the other two — `EmitLayer 0x506a60` and a scale hook `0x506ac0` — are DEAD
// in 5875: the paint callback `0x506a90` tail-calls `0x506aa0`, which is a bare `ret`, and
// every module global it would fill is written by nothing.
//
// Mapping it to `Frame` is therefore the faithful answer, not a 1203 shortcut — and it is
// faithful in the strong sense that `GetObjectType()` must return **"Frame"** and
// `IsObjectType("TaxiRouteFrame")` must be **false**: `0x484800` is not overridden, so the
// name lives only in the 22-entry tag→factory registry and never becomes a class identity.
// The flight-path lines are plain `<Texture>` children the FrameXML creates and rotates
// with the 8-argument `SetTexCoord` we already support; `TaxiRouteFrame.cpp` is a vestigial
// C++ renderer that draws nothing.
"TAXIROUTEFRAME" => FrameKind::Frame,
"BUTTON" => FrameKind::Button,
"CHECKBUTTON" => FrameKind::CheckButton,
// A real registered type (`0x4959a6`), not an alias for Button — decision 1799. Registering
@@ -37,7 +37,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).map(|f| f.shown).unwrap_or(false))
Ok(crate::script::binding_abi::predicate(
model.arena.frame(h).map(|f| f.shown).unwrap_or(false),
))
})?,
)?;
m.set(
@@ -45,11 +47,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model
.arena
.frame(h)
.map(|f| f.effective_visible)
.unwrap_or(false))
Ok(crate::script::binding_abi::predicate(
model
.arena
.frame(h)
.map(|f| f.effective_visible)
.unwrap_or(false),
))
})?,
)?;
// Identity / hierarchy
@@ -669,7 +673,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.is_mouse_enabled(h))
Ok(crate::script::binding_abi::predicate(
model.arena.is_mouse_enabled(h),
))
})?,
)?;
// EnableKeyboard(flag) / IsKeyboardEnabled() — `0x776f90` / `0x776ff0`, real Frame entries in
@@ -703,7 +709,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.is_keyboard_enabled(h))
Ok(crate::script::binding_abi::predicate(
model.arena.is_keyboard_enabled(h),
))
})?,
)?;
// `EnableMouseWheel(flag)` / `IsMouseWheelEnabled()` — the wheel's own gate, a separate flag
@@ -769,7 +777,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.is_mouse_wheel_enabled(h))
Ok(crate::script::binding_abi::predicate(
model.arena.is_mouse_wheel_enabled(h),
))
})?,
)?;
// Clamp-to-screen (`0x776c00`/`0x776cb0`, geometry flags bit4 — layout.md): the layout resolve
@@ -793,7 +803,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.is_clamped_to_screen(h))
Ok(crate::script::binding_abi::predicate(
model.arena.is_clamped_to_screen(h),
))
})?,
)?;
// Hit-rect insets — the MOUSE rect only (widget::Frame::hit_rect_insets): the hit test shrinks
+10 -6
View File
@@ -404,14 +404,18 @@ fn set_flag(lua: &Lua, this: &Table, which: Flag, value: bool) -> mlua::Result<(
Ok(())
}
fn get_flag(lua: &Lua, this: &Table, which: Flag) -> mlua::Result<bool> {
/// `IsMovable`/`IsResizable`/`IsUserPlaced` — the NUMBER 1 or nil, never a boolean (1830,
/// [`crate::script::binding_abi::predicate`]).
fn get_flag(lua: &Lua, this: &Table, which: Flag) -> mlua::Result<Value> {
let h = frame_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.frame(h).is_some_and(|f| match which {
Flag::Movable => f.movable,
Flag::Resizable => f.resizable,
Flag::UserPlaced => f.user_placed,
}))
Ok(crate::script::binding_abi::predicate(
model.arena.frame(h).is_some_and(|f| match which {
Flag::Movable => f.movable,
Flag::Resizable => f.resizable,
Flag::UserPlaced => f.user_placed,
}),
))
}
/// The family's refusal, named frame and all — the reference's own shape (it formats the frame's
@@ -125,7 +125,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.is_toplevel(h))
Ok(crate::script::binding_abi::predicate(
model.arena.is_toplevel(h),
))
})?,
)?;
// Raise() — `0x775a50` → `0x76a5b0` → `0x7650f0(this, force = 1)`. Note it is legal on any
+2 -1
View File
@@ -220,7 +220,8 @@ fn disabled_button_swallows_clicks_checkbutton_toggles_before_onclick() {
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
assert!(s.eval::<bool>("return seen_checked == true").unwrap());
// `seen_checked` is whatever `GetChecked()` handed the handler — the NUMBER 1 (1830).
assert!(s.eval::<bool>("return seen_checked == 1").unwrap());
assert!(s.eval::<bool>("return Toggler:GetChecked()").unwrap());
// Programmatic Click() rides the same path: toggles back off.
+3 -1
View File
@@ -17,7 +17,9 @@ fn enable_mouse_gates_hit_testing() {
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(800, 600); b:EnableMouse(false)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
assert(a:IsMouseEnabled() == true and b:IsMouseEnabled() == false)
-- 1/nil, not a boolean (1830): this is the exact comparison shape that inverts, so the
-- test asserts it rather than leaning on truthiness.
assert(a:IsMouseEnabled() == 1 and b:IsMouseEnabled() == nil)
"#,
)
.unwrap();
+11 -7
View File
@@ -181,23 +181,27 @@ pub(super) fn setup_cloud_layer(
/// A WMO skybox ([`crate::skybox`]) hides the clouds too: `CSky::Render`'s one shared boolean skips
/// **all six** sky elements together (`0x6d4a3b test edi,edi; je`), so the painted art is the whole
/// sky rather than a backdrop the procedural clouds keep drawing over. The art carries its own cloud
/// banks; layering ours on top lifted the painted zenith out of its near-black.
/// banks; layering ours on top lifted the painted zenith out of its near-black. The gate is the
/// slot's **weight** past 0.99 ([`crate::skybox::SkyboxWeight`]): through the body of the 4-second
/// crossfade the clouds keep drawing and the painted sky alpha-blends over them.
///
/// A **submerged eye** hides them the same way: the scene driver's `0x6812a4` submerged test skips
/// the whole `CSky::Render` call (byte-VERIFIED, wow-re terrain "the liquid render state") — from
/// under the surface there is no sky, only the murk.
pub(super) fn apply_cloud_visibility(
debug: Res<DebugState>,
skybox: Res<crate::skybox::CameraSkybox>,
skybox: Res<crate::skybox::SkyboxWeight>,
underwater: Res<crate::liquid::Underwater>,
mut dome: Query<&mut Visibility, With<CloudDome>>,
) {
if let Ok(mut vis) = dome.single_mut() {
*vis = if debug.lighting.disable_sky_dome || skybox.0.is_some() || underwater.0.any() {
Visibility::Hidden
} else {
Visibility::Inherited
};
*vis =
if debug.lighting.disable_sky_dome || skybox.replaces_celestial() || underwater.0.any()
{
Visibility::Hidden
} else {
Visibility::Inherited
};
}
}
+10 -1
View File
@@ -23,6 +23,7 @@ pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES};
// — stays in the crate. Off-world producers state values through `LightBlob` and never a row index
// (see `blob`); `global_light` builds the world's own blob on the frame path.
pub(crate) use prop_probes::prop_probe_region_offset;
pub use resolve::WmoCrossfade;
use resolve::{apply_sky_backdrop, setup_lighting, update_time_lighting};
pub(crate) use sh::prop_probe_coeffs;
@@ -52,7 +53,7 @@ pub struct WowLighting {
pub(crate) fog_start: f32,
pub(crate) fog_end: f32,
/// The **interior** fog triple (DNState+0x80/84/88): `lerp(scene fog → the claimed WMO's MFOG
/// fog, t)` on the 4 s camera-in-WMO ramp ([`resolve::WmoFogRamp`]) — equal to the scene triple while
/// fog, t)` on the 4 s camera-in-WMO ramp ([`WmoCrossfade`]) — equal to the scene triple while
/// the camera is outside. Consumed ONLY by the interior lanes (round-6 Q-I consumer map): the
/// interior WMO-group surfaces and THAT group's doodads (`0x6b5190` / the group-doodad drawer
/// `0x6b62e0`) — `wow_model.wgsl` selects it by the material interior flag. Terrain, liquid,
@@ -279,6 +280,12 @@ pub struct GameClock {
pub source: ClockSource,
}
/// Ordering handle: [`WowLighting`] and [`WmoCrossfade`] are resolved for the frame after this
/// set. The skybox weight resolve hangs off it — the sky reading a stale crossfade is a frame
/// where the painted sky and the fog disagree about how far into the building you are.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct LightingResolveSet;
/// The lighting subsystem: registers the WoW-light resource + game-clock, a **black** background
/// (Phase 0 — the DBC sky comes back in a later step), and the two per-frame systems that resolve the
/// `Light.dbc` values for the time of day and push them onto the materials.
@@ -290,12 +297,14 @@ impl Plugin for LightingPlugin {
.init_resource::<PropProbes>()
.init_resource::<GameClock>()
.init_resource::<WorldTime>()
.init_resource::<WmoCrossfade>()
.insert_resource(ClearColor(Color::BLACK))
.add_systems(Startup, setup_lighting.after(AssetSet::Open))
.add_systems(
Update,
(update_time_lighting, apply_sky_backdrop)
.chain()
.in_set(LightingResolveSet)
// The storm blend reads this frame's weather densities (decision 0302).
.after(crate::weather::WeatherTick)
// …and this frame's game clock, which whatever owns the session publishes in
+26 -17
View File
@@ -1,6 +1,6 @@
//! The per-frame time-of-day RESOLVE: samples `Light.dbc` against the effective game clock (server
//! clock, manual debug-panel scrub, or the pre-connect noon fallback) into a fresh [`super::WowLighting`]
//! — the scene-fog stage, the camera-in-WMO interior-fog crossfade ([`WmoFogRamp`]), and the startup
//! — the scene-fog stage, the camera-in-WMO interior-fog crossfade ([`WmoCrossfade`]), and the startup
//! seed ([`setup_lighting`]) all live here. Split from the resource type + plugin registration in
//! `super`.
@@ -31,22 +31,31 @@ fn scene_fog(fog_end_raw: f32, fog_start_frac: f32, farclip: f32) -> (f32, f32)
/// out (`0x6cefcb–0x6cf051`, wow-re `rf-weather-emission-timeline` ROUND 5).
const WMO_FOG_RAMP_PER_SEC: f32 = 0.25;
/// The camera-in-WMO **interior fog** blend (`[0xce9bdc]` + the staging `0x6cef43–62`): while the
/// camera stands in a WMO interior, the scene fog — storm veil included — crossfades toward the
/// building's own MFOG fog over 4 s, and back out over 4 s on leaving. This is why the reference's
/// Goldshire inn keeps its warm authored haze while a storm rages outside (the storm's
/// negative-start veil never reaches the room). The staged triple LATCHES while the camera exits so
/// the fade-out lerps from the room's fog instead of popping. A mid-interior fog-record switch
/// (tavern → corridor) snaps the staged target — the client's source-identity guard (`0x6cef92–b5`)
/// re-arms a lerp there whose exact semantics are un-carved (flagged in 0338); at room scales both
/// records' fog is ≈0 so the step is invisible.
#[derive(Default)]
pub(super) struct WmoFogRamp {
/// The camera-in-WMO **interior crossfade** — the reference's `[0xce9bdc]` (+ the staging
/// `0x6cef43–62`), ONE number with exactly two consumers: while the camera stands in a WMO
/// interior, the scene fog — storm veil included — crossfades toward the building's own MFOG fog
/// over 4 s and back out over 4 s on leaving, **and the same `t` is the WMO skybox's slot alpha**
/// ("the skybox alpha and the interior fog blend are the same number" — wow-re `wmo-skybox.md` §3,
/// VERIFIED; `crate::skybox` reads it through [`Self::t`], never a ramp of its own). This is why
/// the reference's Goldshire inn keeps its warm authored haze while a storm rages outside (the
/// storm's negative-start veil never reaches the room), and why Stratholme's painted sky fades in
/// at exactly the rate its streets shed the scene fog. The staged triple LATCHES while the camera
/// exits so the fade-out lerps from the room's fog instead of popping. A mid-interior fog-record
/// switch (tavern → corridor) snaps the staged target — the client's source-identity guard
/// (`0x6cef92–b5`) re-arms a lerp there whose exact semantics are un-carved (flagged in 0338); at
/// room scales both records' fog is ≈0 so the step is invisible.
#[derive(Resource, Default)]
pub struct WmoCrossfade {
t: f32,
staged: Option<crate::wmo_portal::WmoFogTarget>,
}
impl WmoFogRamp {
impl WmoCrossfade {
/// The crossfade weight this frame, `[0, 1]` — advanced only by [`update_time_lighting`]'s
/// blend, read by the skybox weight resolve.
pub fn t(&self) -> f32 {
self.t
}
/// Advance the ramp by `dt` toward `target` and return the committed `(color, start, end)`.
/// The staging transform is the record's own law: `end = min(end, farclip)`,
/// `start = end × start_scalar` (`fmul 0x6cef56` — MFOG start IS a fraction of end in 1.12.1).
@@ -197,7 +206,7 @@ pub(super) fn update_time_lighting(
view: Res<crate::view::ViewDistance>,
time: Res<Time>,
wmo_fog: Res<crate::wmo_portal::CameraWmoFog>,
mut wmo_ramp: Local<WmoFogRamp>,
mut wmo_ramp: ResMut<WmoCrossfade>,
mut clock: ResMut<GameClock>,
mut lighting: ResMut<WowLighting>,
mut last_dump: Local<Option<u32>>,
@@ -297,7 +306,7 @@ pub(super) fn update_time_lighting(
// commits 125/500 clear and −139/278 at full storm — the storm's −0.5 fraction yields the
// negative start = the near veil; the short storm end whites out the middle distance.
let (fog_start, fog_end) = scene_fog(atmo.fog_end, atmo.fog_start_frac, view.farclip);
// The camera-in-WMO interior-fog crossfade (see [`WmoFogRamp`]) — computed as its OWN triple;
// The camera-in-WMO interior-fog crossfade (see [`WmoCrossfade`]) — computed as its OWN triple;
// the scene fog above stays untouched (the round-5 global-overwrite washed the outside view
// golden through the door — director-caught, corrected per the round-6 Q-I lane map).
// Submerged, the submerged Light param owns everything — the MFOG record's own underwater
@@ -482,7 +491,7 @@ mod tests {
/// releases the latch at zero.
#[test]
fn wmo_fog_ramp_fades_in_and_out_over_four_seconds() {
let mut ramp = WmoFogRamp::default();
let mut ramp = WmoCrossfade::default();
let target = WmoFogTarget {
color: [1.0, 0.5, 0.0],
end: 194.4,
@@ -514,7 +523,7 @@ mod tests {
/// vs `[0xce9b94]`), and start scales off the CLAMPED end.
#[test]
fn wmo_fog_staging_clamps_end_to_farclip() {
let mut ramp = WmoFogRamp::default();
let mut ramp = WmoCrossfade::default();
let target = WmoFogTarget {
color: [1.0; 3],
end: 444.4,
+1 -1
View File
@@ -14,7 +14,7 @@ use benilla_assets::materials::{WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY
mod batch;
mod visibility;
pub use batch::{BatchVariants, M2BatchMaterials, ModelMaterials};
pub use batch::{BatchVariants, M2BatchMaterials, ModelMaterials, SkyboxBatch};
/// Yards of transparent-pass sort bias per authored batch-order step (`MatKey::batch_order` ×
/// this, on `StandardMaterial::depth_bias` — bevy adds that field to the phase item's sort
+65 -35
View File
@@ -58,6 +58,16 @@ pub struct BatchVariants {
pub zfill: Option<Handle<WowModelMaterial>>,
}
/// The pair one skybox batch draws with ([`M2BatchMaterials::skybox`]): the authored blend at full
/// weight, and the promoted SRC_ALPHA twin the 4-second crossfade rides (equal to `steady` when the
/// authored blend already blends).
pub struct SkyboxBatch {
/// The batch at slot weight 1.0 — its authored blend mode, the material it has always had.
pub steady: Handle<WowModelMaterial>,
/// The blend-promotion twin for `0 < weight < 1` (wow-re `m2-blend-promotion-zfill.md`).
pub fade_blend: Handle<WowModelMaterial>,
}
/// Build the material(s) an authored render batch draws with.
///
/// Every method returns `None` until the shared light buffer exists (it is created at startup in
@@ -101,7 +111,7 @@ impl M2BatchMaterials<'_> {
))
}
/// One material for a batch of a **WMO skybox** — the painted sky a building owns
/// The material pair for a batch of a **WMO skybox** — the painted sky a building owns
/// ([`crate::skybox`]), which is an ordinary M2 and is drawn as one (decision 1264).
///
/// Everything that makes it a *skybox* rather than a doodad is here, and it is only three
@@ -125,47 +135,67 @@ impl M2BatchMaterials<'_> {
/// (`[0.975, 0.98]`, painter's order, depth-write off); our port of that ordering is the sort
/// rung ([`super::skybox_sort_bias`]), so honouring the flag here would be reading it twice.
///
/// No variant set: a skybox never feathers, never primes depth, and is never indoors or
/// outdoors — it *is* the outdoors.
/// **The skybox DOES feather** — that is what the second handle is. The slot weight
/// (`[CM2Model+0x180]`, written per frame from the interior crossfade `[0xce9bdc]`) multiplies
/// into every batch's combined alpha, and a batch at `0 < A < 1` is *promoted* to
/// SRC_ALPHA/INV_SRC_ALPHA blending whatever its authored mode (wow-re
/// `m2-blend-promotion-zfill.md` — the same promotion every entity fade rides). So the 4-second
/// crossfade draws the sky alpha-blended over the still-standing celestial pass. `fade_blend`
/// is that promoted twin; a batch whose steady material already blends (authored Blend, or a
/// multiply batch riding the source-colour lerp) is its own twin, exactly
/// [`Self::entity_variants`]' law. No depth-prime twin: the sky never writes depth, so there
/// is nothing to prime ([`Self::zfill_for`]'s own rule).
pub fn skybox(
&mut self,
sub: &benilla_formats::RenderSubmesh,
texture: Option<Handle<Image>>,
order: u16,
) -> Option<Handle<WowModelMaterial>> {
) -> Option<SkyboxBatch> {
let light = self.light.as_ref()?.0.clone();
Some(model_material(
&mut self.cache.0,
&mut self.materials,
texture,
let mut mk = |fade: bool| {
model_material(
&mut self.cache.0,
&mut self.materials,
texture.clone(),
sub.blend,
sub.two_sided,
false, // an M2, whichever building names it
false,
sub.emissive,
sub.additive,
fade,
true, // never writes depth
false, // …and never skips the test — see above
sub.fog_policy,
sub.env_map,
ShadeSel::Lit, // unread: every skybox batch the chain ships authors UNLIT (0x01)
order,
// No texture-transform or M2Color loop is wired on this lane, and neither shipped
// skybox authors one (`CavernsOfTimeSky` has 0 texture transforms and 0 animated colour
// tracks; `StratholmeSkybox` is fully static). Wiring them means loading the skybox
// through the `M2Model` asset lane — which holds these as the `Arc`s the material key
// identifies them by, and is also what the asteroid belts' BONE animation needs. One
// move, when that lands; a locally-minted `Arc` here would be a dedup key with no owner.
None,
None,
None, // M2: no MOBA class, no SIDN, no WINDOW
None,
false,
true, // the sky lane
&light,
None, // one shared sky material — no per-sequence channel to key on
)
};
let steady = mk(false);
let fade_blend = if matches!(
sub.blend,
sub.two_sided,
false, // an M2, whichever building names it
false,
sub.emissive,
sub.additive,
false, // never feathers
true, // …never writes depth
false, // …and never skips the test — see above
sub.fog_policy,
sub.env_map,
ShadeSel::Lit, // unread: every skybox batch the chain ships authors UNLIT (0x01)
order,
// No texture-transform or M2Color loop is wired on this lane, and neither shipped
// skybox authors one (`CavernsOfTimeSky` has 0 texture transforms and 0 animated colour
// tracks; `StratholmeSkybox` is fully static). Wiring them means loading the skybox
// through the `M2Model` asset lane — which holds these as the `Arc`s the material key
// identifies them by, and is also what the asteroid belts' BONE animation needs. One
// move, when that lands; a locally-minted `Arc` here would be a dedup key with no owner.
None,
None,
None, // M2: no MOBA class, no SIDN, no WINDOW
None,
false,
true, // the sky lane
&light,
None, // one shared sky material — no per-sequence channel to key on
))
ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x
) {
steady.clone()
} else {
mk(true)
};
Some(SkyboxBatch { steady, fade_blend })
}
/// One steady material for a batch drawn **off-world**, against a render target's own light
+11 -6
View File
@@ -251,20 +251,25 @@ fn follow_camera(
/// test gates `CSky::Render 0x6d4940` — stars, discs, gradient band and cloud dome together;
/// byte-VERIFIED, wow-re terrain "the liquid render state"). With the dome hidden the `ClearColor`
/// backdrop (the row-7 fog colour — submerged, the murk — or black via "black backdrop") shows through.
///
/// The skybox gate is its **weight**, not the resolve: below the 0.99 threshold the dome still
/// draws and the painted sky alpha-blends OVER it — the visible half of the 4-second crossfade
/// ([`crate::skybox::SkyboxWeight`]).
fn apply_sky_visibility(
debug: Res<DebugState>,
skybox: Res<crate::skybox::CameraSkybox>,
skybox: Res<crate::skybox::SkyboxWeight>,
underwater: Res<crate::liquid::Underwater>,
mut dome: Query<&mut Visibility, With<Sky>>,
) {
let Ok(mut vis) = dome.single_mut() else {
return;
};
let want = if debug.lighting.disable_sky_dome || skybox.0.is_some() || underwater.0.any() {
Visibility::Hidden
} else {
Visibility::Visible
};
let want =
if debug.lighting.disable_sky_dome || skybox.replaces_celestial() || underwater.0.any() {
Visibility::Hidden
} else {
Visibility::Visible
};
if *vis != want {
*vis = want;
}
+133 -18
View File
@@ -65,11 +65,26 @@
//! in [`crate::sky_order`]), so the world paints over it and the shell is free to sit inside the
//! room's own geometry.
//!
//! **The slot has a WEIGHT, and the weight is the 4-second crossfade.** The WMO slot is filled per
//! frame as `0x6d4810(0, [0xca8080], [0xce9bdc])` — and `[0xce9bdc]` is the camera-in-WMO interior
//! crossfade, THE same number the MFOG fog lerp rides (±0.25/s = `[0x8115b0]`, clamped [0,1];
//! wow-re `wmo-skybox.md` §3: "the skybox alpha and the interior fog blend are the same number").
//! A slot draws only at weight > 0 (`0x6d4afe`), with the weight multiplied into every batch's
//! combined alpha (`0x710cb0` → `[CM2Model+0x180]`) — and a batch at `0 < A < 1` is promoted to
//! SRC_ALPHA blending whatever its authored mode (`m2-blend-promotion-zfill.md`), so walking
//! through Stratholme's gate alpha-blends the painted sky in over the still-standing celestial
//! pass over 4 seconds, and back out on leaving. Here: [`crate::lighting::WmoCrossfade`] publishes
//! the number, [`SkyboxWeight`] resolves the slot's weight from it (the DBC/ghost slot is
//! hardcoded 1.0 — that pop is faithful), and [`apply_skybox_visibility`] applies it through the
//! entity feather rails (`MeshTag` alpha + the promotion twin).
//!
//! **Scope — the skybox replaces the WHOLE celestial pass, not just the backdrop.** `CSky::Render`
//! carries one shared boolean (`0x6d49cd` sets it, `0x6d49fb`/`0x6d4a2e` clear it once any slot's
//! weight exceeds `[0x808aac]` = 0.99) and `0x6d4a3b test edi,edi; je` skips **all six** element
//! draws together — stars, sun disc, both moons, gradient band and cloud dome. There is no
//! per-element gating. Confirmed in a live GL capture of the reference standing in King's Square:
//! per-element gating; below the 0.99 threshold — i.e. for all but the last 40 ms of the fade —
//! the six elements still draw and the skybox blends over them ([`SkyboxWeight::replaces_celestial`]).
//! Confirmed in a live GL capture of the reference standing in King's Square:
//! the entire `[0.975, 0.98]` sky slice is three `count=12` draws (the cube's three texture pairs)
//! and nothing else, identically in every frame. Only the **glare** quads survive — they render on
//! their own later path (`0x483740 → 0x6d48c0 → 0x7e57e0`), outside this pass.
@@ -80,12 +95,13 @@
use std::collections::HashSet;
use bevy::asset::RenderAssetUsages;
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::mesh::{Indices, MeshTag, PrimitiveTopology};
use bevy::prelude::*;
use crate::model_render::M2BatchMaterials;
use crate::view::WorldCamera;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WmoModel;
use benilla_assets::{LockRecover, WorldAssets};
@@ -100,10 +116,50 @@ const SHOW_SKYBOX: u32 = 0x40000;
#[derive(Resource, Default, PartialEq, Eq)]
pub struct CameraSkybox(pub Option<String>);
/// The resolved skybox's slot WEIGHT this frame — the second half of the resolve, and the number
/// that makes the sky *crossfade* instead of pop (the report this closes). Per the byte law
/// (module header + wow-re `wmo-skybox.md` §3–5):
///
/// - the **WMO slot's** weight is `[0xce9bdc]`, the camera-in-WMO interior crossfade — THE same
/// number the MFOG fog lerp rides ([`crate::lighting::WmoCrossfade`], ±0.25/s, a 4-second fade
/// in and out). Engagement is the fog's own (claim + MFOG count > 1 + a true-interior
/// containing group), so at Stratholme's gate the painted sky fades in over the celestial pass
/// exactly as the storm veil fades out of the streets;
/// - the **DBC/ghost slot's** weight is hardcoded `1.0` whenever it is filled (`0x6d2260`) — the
/// ghost sky pops, faithfully;
/// - `0.0` when no skybox resolves — including a skybox *named* by the flood while the crossfade
/// sits at 0 (standing outside the city gate: the flood reaches flagged groups through the
/// doorway and publishes the name, but the reference draws a slot only at weight > 0, so no
/// painted sky shows until you are actually in).
///
/// Consumers: [`apply_skybox_visibility`] (draw gate `weight > 0` = `0x6d4afe`, and the per-batch
/// alpha), and the celestial stand-down gates in [`crate::sky`], [`crate::sun`] and
/// [`crate::clouds`] via [`Self::replaces_celestial`].
#[derive(Resource, Default, PartialEq)]
pub struct SkyboxWeight(pub f32);
impl SkyboxWeight {
/// Does the skybox replace the whole celestial pass this frame? `weight > 0.99`
/// (`[0x808aac]`, tested at `0x6d49e8`/`0x6d4a1f`): below it the six celestial elements
/// still draw and the skybox alpha-blends OVER them — that compositing is the visible half
/// of the 4-second crossfade.
pub fn replaces_celestial(&self) -> bool {
self.0 > 0.99
}
}
/// Marks one batch of a built skybox model, tagged with the model path it belongs to (a session can
/// walk through more than one skybox building, and the built entities are cached, not rebuilt).
#[derive(Component)]
struct SkyboxPart(String);
struct SkyboxPart {
/// The skybox model path this batch belongs to.
path: String,
/// The batch's authored-blend material — what it draws with at weight 1.0.
steady: Handle<WowModelMaterial>,
/// The blend-promotion twin for `0 < weight < 1` (`m2-blend-promotion-zfill.md`; equal to
/// `steady` for a batch whose authored mode already blends).
fade_blend: Handle<WowModelMaterial>,
}
/// This batch **spins**: every one of its vertices is wholly weighted to one parentless
/// rotation-only bone, so the authored motion is a rigid transform about that bone's pivot and needs
@@ -150,6 +206,7 @@ impl Plugin for SkyboxPlugin {
// No `MaterialPlugin` of its own any more: a skybox batch is a `WowModelMaterial` like every
// other model batch, and that plugin is already loaded.
app.init_resource::<CameraSkybox>()
.init_resource::<SkyboxWeight>()
.init_resource::<BuiltSkyboxes>()
.add_systems(
Update,
@@ -160,6 +217,10 @@ impl Plugin for SkyboxPlugin {
// move that changes rooms lands a frame late, or not at all on the frame it
// matters, and the backdrop flickers between the painted sky and the gradient.
.after(crate::wmo_portal::WmoPvsSet)
// …and the slot weight is this frame's crossfade `t`, advanced by the lighting
// resolve — a stale read here is a frame where the sky's alpha and the interior
// fog disagree about how far into the building the camera is.
.after(crate::lighting::LightingResolveSet)
.in_set(SkyboxResolve),
)
// Camera-anchored placement runs post-propagation off the SAME-frame camera pose — the
@@ -177,12 +238,14 @@ impl Plugin for SkyboxPlugin {
/// carry `SHOW_SKYBOX`, and does its root name a MOSB?*
///
/// That distinction is the whole bug this replaced. Reading the **containing** group instead is
/// wrong twice over at Stratholme's King's Square: the camera stands in group 39, the root's only
/// EXTERIOR (`0x8`) group, which does not set `SHOW_SKYBOX` — and [`CameraInteriorClaim`] is
/// deliberately `None` over an EXTERIOR group's floor, so the old resolve could not even see the
/// placement. The reference draws the painted sky there regardless, because 61 of the 83 groups the
/// flood reaches from group 39 do carry the bit. (Verified on the asset: BFS over `Stratholme_B`'s
/// MOPR reaches 82 of 83 groups from group 39, 61 of them flagged.)
/// wrong at Stratholme's King's Square: the BSP containment there is group 39, the root's only
/// EXTERIOR (`0x8`) group, which does not set `SHOW_SKYBOX` — yet the reference draws the painted
/// sky, because 61 of the 83 groups the flood reaches from group 39 do carry the bit. (Verified on
/// the asset: BFS over `Stratholme_B`'s MOPR reaches 82 of 83 groups from group 39, 61 of them
/// flagged.) The camera's down-ray CLAIM is a different resolver and is *not* cleared there — at
/// the reported spot it seeds the street group g08 (`0x42805`, a true interior; pin-probed
/// 2026-09-01, decision 1827) — which is what lets the crossfade weight engage in the square while
/// the skybox *name* comes from the flood predicate above.
#[allow(clippy::too_many_arguments)]
fn resolve_camera_skybox(
instances: Query<&crate::wmo_portal::WmoPortalInstance>,
@@ -191,7 +254,9 @@ fn resolve_camera_skybox(
viewer: Res<crate::view::Viewer>,
current_map: Option<Res<crate::world_map::CurrentMap>>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
crossfade: Res<crate::lighting::WmoCrossfade>,
mut want: ResMut<CameraSkybox>,
mut weight: ResMut<SkyboxWeight>,
) {
// **The ghost sky first — it wins outright.** Slot B is filled at weight 1.0 and that makes the
// reference skip loop A entirely (module header, wow-re §3–4), so a dead player under
@@ -210,6 +275,10 @@ fn resolve_camera_skybox(
if want.0.as_deref() != Some(sky) {
want.0 = Some(sky.to_owned());
}
// Slot B's weight is hardcoded 1.0 whenever it is filled (`0x6d26cb`/`0x6d26d0`):
// the ghost sky pops in whole, and — being > 0.99 — replaces the celestial pass on
// the same frame the death atmosphere lands.
weight.set_if_neq(SkyboxWeight(1.0));
return;
}
}
@@ -242,6 +311,14 @@ fn resolve_camera_skybox(
if want.0 != resolved {
want.0 = resolved;
}
// The WMO slot draws at the interior crossfade's weight — `[0xce9bdc]`, the SAME number the
// MFOG fog lerp rides — and at 0.0 when nothing resolved. A resolved name at weight 0 is a
// real state (the flood publishes through a doorway the camera hasn't walked into), and it is
// exactly the reference's: the slot is filled, and `0x6d4afe` declines to draw it.
weight.set_if_neq(SkyboxWeight(match want.0 {
Some(_) => crossfade.t(),
None => 0.0,
}));
}
/// Build the wanted skybox's entities the first time it is asked for. The models are small (3 batches
@@ -327,16 +404,24 @@ fn build_skybox(
// `i + 1` is the authored-batch-order convention (`0` = unordered): the transparent half of
// a skybox is camera-anchored, so every one of its batches shares a sort distance and the
// order is the only thing keeping the layers from re-flipping every frame.
let Some(material) = mats.skybox(sub, texture, u16::try_from(i + 1).unwrap_or(0)) else {
let Some(pair) = mats.skybox(sub, texture, u16::try_from(i + 1).unwrap_or(0)) else {
return; // light buffer vanished mid-build; `built` is unlatched, so we retry
};
let part = commands
.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(material),
MeshMaterial3d(pair.steady.clone()),
Transform::default(),
Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one
SkyboxPart(path.to_string()),
// The per-instance alpha the crossfade writes (bits 0..=5, the one field this
// population uses — no rig, no shade/probe, no flags; `apply_skybox_visibility`
// is its sole writer).
MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)),
SkyboxPart {
path: path.to_string(),
steady: pair.steady,
fade_blend: pair.fade_blend,
},
))
.id();
if let Some(spin) = sole_bone(sub).and_then(|b| spins.get(&b)) {
@@ -370,15 +455,30 @@ fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option<u16> {
.then_some(bone)
}
/// Show the wanted skybox's batches and hide every other built one. This is the sole `Visibility`
/// writer for these entities (the gradient dome's own gate lives in [`crate::sky`], which reads the
/// same resource — one authority per entity class, decision 0025).
/// Show the wanted skybox's batches at this frame's slot weight and hide every other built one —
/// the draw half of the 4-second crossfade. This is the sole `Visibility`, material and `MeshTag`
/// writer for these entities (the gradient dome's own gate lives in [`crate::sky`], which reads
/// [`SkyboxWeight`] — one authority per entity class, decision 0025).
///
/// Per slot the reference draws only at weight > 0 (`0x6d4afe fcomp 0.0`), with the weight
/// multiplied into every batch's combined alpha (`0x710cb0` → `[CM2Model+0x180]`,
/// `m2-alpha-combine-cull.md`) — and a batch at `0 < A < 1` is PROMOTED to
/// SRC_ALPHA/INV_SRC_ALPHA blending whatever its authored mode (`m2-blend-promotion-zfill.md`),
/// which is what lets an opaque painted cube fade. Here that is: weight ≤ 0 ⇒ hidden; weight < 1
/// ⇒ the blend-promotion twin + the weight in the `MeshTag` alpha field (the same rails every
/// entity feather rides); weight ≥ 1 ⇒ the steady authored material, alpha field full.
fn apply_skybox_visibility(
want: Res<CameraSkybox>,
mut parts: Query<(&SkyboxPart, &mut Visibility)>,
weight: Res<SkyboxWeight>,
mut parts: Query<(
&SkyboxPart,
&mut Visibility,
&mut MeshMaterial3d<WowModelMaterial>,
&mut MeshTag,
)>,
) {
for (part, mut vis) in &mut parts {
let show = want.0.as_deref() == Some(part.0.as_str());
for (part, mut vis, mut mat, mut tag) in &mut parts {
let show = weight.0 > 0.0 && want.0.as_deref() == Some(part.path.as_str());
let target = if show {
Visibility::Visible
} else {
@@ -387,6 +487,21 @@ fn apply_skybox_visibility(
if *vis != target {
*vis = target;
}
if !show {
continue;
}
let handle = if weight.0 < 1.0 {
&part.fade_blend
} else {
&part.steady
};
if mat.0 != *handle {
mat.0 = handle.clone();
}
let bits = crate::mesh_tag::with_alpha(tag.0, weight.0);
if tag.0 != bits {
tag.0 = bits;
}
}
}
+6 -2
View File
@@ -148,6 +148,10 @@ impl Plugin for SunPlugin {
/// five. Live-confirmed: standing in King's Square the reference's whole `[0.975, 0.98]` slice is
/// three `count=12` draws — the skybox cube — and nothing else.
///
/// The gate is the slot's **weight**, never the resolve alone: through the body of the 4-second
/// crossfade (weight ≤ 0.99) the six elements keep drawing and the skybox alpha-blends over them
/// ([`crate::skybox::SkyboxWeight`]).
///
/// The **submerged eye** suppresses the same set one level up: the scene driver's `0x6812a4`
/// submerged test skips the whole `CSky::Render` call (byte-VERIFIED, wow-re terrain "the liquid
/// render state" — "the surface is drawn identically from below; what changes underwater is
@@ -163,13 +167,13 @@ impl Plugin for SunPlugin {
/// own authority (decision 0025) reading these same resources.
#[allow(clippy::type_complexity)]
fn apply_celestial_visibility(
skybox: Res<crate::skybox::CameraSkybox>,
skybox: Res<crate::skybox::SkyboxWeight>,
underwater: Res<crate::liquid::Underwater>,
mut suns: Query<(&SunSprite, &mut Visibility), Without<MoonSprite>>,
mut moons: Query<(&MoonSprite, &mut Visibility), Without<SunSprite>>,
mut stars: Query<&mut Visibility, (With<StarDome>, Without<SunSprite>, Without<MoonSprite>)>,
) {
let suppressed = skybox.0.is_some() || underwater.0.any();
let suppressed = skybox.replaces_celestial() || underwater.0.any();
// `Inherited`, not `Visible`: these sprites hang off the celestial rig and must keep deferring to
// it when the skybox stands down, exactly as the cloud dome's gate does.
let want = |in_sky_pass: bool| {
+3 -1
View File
@@ -19,7 +19,9 @@
//! This module only picks the **target** triple ([`CameraWmoFog`], written by the PVS pass — the
//! camera's group falls out of the portal flood's down-ray seed for free). The 4-second crossfade
//! ramp and the actual scene-fog lerp live in `crate::lighting` (`update_time_lighting`), the
//! benilla twin of `0x6cee30`.
//! benilla twin of `0x6cee30` — and the ramp value it publishes (`WmoCrossfade`) is ALSO the WMO
//! skybox's slot alpha: "the skybox alpha and the interior fog blend are the same number"
//! (wow-re `wmo-skybox.md` §3; `crate::skybox`).
//!
//! One explicitly-chosen reading remains: portal-less props never run the flood (all-visible fast
//! path), so they never engage — no shipped prop is an enterable room. (The claim mask itself is