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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user