Merge upstream/main (samwhosung/benilla 66388278, 78 commits) into everwood-cs

Keeps both sides: upstream's settings rework, model_render rework, mesh-tag change-gating,
static_gx node move + diagnostics span, WorldPointLight, brightness/weather options,
weapon trails; and the MONKEY lighting stack (sun shadow lanes, dynamic interiors, cube-map
torch shadows, daylight fixtures, spell lights, bake floor, K=12 exterior selection, debug panel).
All MONKEY light lanes migrated from Bevy PointLight to upstream's WorldPointLight.
1091 world+formats tests green; all 8 WGSL probes OK; server-less smoke (inn, Northshire) clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-17 10:46:19 +01:00
co-authored by Claude Fable 5.1
1045 changed files with 157434 additions and 61619 deletions
Generated
+73 -27
View File
@@ -121,6 +121,18 @@ dependencies = [
"libc",
]
[[package]]
name = "alsa"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
dependencies = [
"alsa-sys",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
@@ -508,6 +520,7 @@ dependencies = [
"bytemuck",
"coreaudio-rs 0.13.0",
"cosmic-text",
"cpal 0.17.1",
"crossbeam-channel",
"fontdb",
"half",
@@ -523,12 +536,14 @@ dependencies = [
"objc2-foundation 0.2.2",
"rand 0.8.6",
"raw-window-handle",
"regex",
"rtrb",
"serde",
"smithay-clipboard",
"symphonia",
"toml",
"wgpu",
"windows-sys 0.59.0",
]
[[package]]
@@ -862,7 +877,7 @@ dependencies = [
"bevy_reflect",
"bevy_transform",
"coreaudio-sys",
"cpal",
"cpal 0.15.3",
"rodio",
"tracing",
]
@@ -2641,14 +2656,14 @@ version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"alsa 0.9.1",
"core-foundation-sys",
"coreaudio-rs 0.11.3",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"mach2 0.4.3",
"ndk 0.8.0",
"ndk-context",
"oboe",
@@ -2658,6 +2673,36 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "cpal"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
dependencies = [
"alsa 0.10.0",
"coreaudio-rs 0.13.0",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2 0.5.0",
"ndk 0.9.0",
"ndk-context",
"num-derive",
"num-traits",
"objc2 0.6.4",
"objc2-audio-toolbox",
"objc2-avf-audio",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.62.2",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -4074,8 +4119,6 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kira"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "065632c16b39f4c00bc5514de0698474d1b6c36120743a154c78ca27ffc1ffe1"
dependencies = [
"atomic-arena",
"glam 0.33.2",
@@ -4108,17 +4151,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lewton"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
dependencies = [
"byteorder",
"ogg",
"tinyvec",
]
[[package]]
name = "libc"
version = "0.2.186"
@@ -4247,6 +4279,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -4672,6 +4713,16 @@ dependencies = [
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-avf-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.2.2"
@@ -4706,6 +4757,7 @@ dependencies = [
"objc2 0.6.4",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
@@ -4737,7 +4789,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.1",
"block2 0.6.2",
"dispatch2",
"libc",
"objc2 0.6.4",
]
@@ -4804,6 +4858,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-core-foundation",
]
@@ -4969,15 +5025,6 @@ dependencies = [
"nonmax",
]
[[package]]
name = "ogg"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -5639,8 +5686,7 @@ version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
dependencies = [
"cpal",
"lewton",
"cpal 0.15.3",
]
[[package]]
+36 -10
View File
@@ -14,7 +14,27 @@ authors = ["sam <samuv.eth@gmail.com>"]
# and a workspace default-features choice can't be subtracted from downstream.
[workspace.dependencies]
anyhow = "1.0.102"
bevy = "0.18.1"
# bevy's `default` is `["2d", "3d", "ui"]`, and every one of those three pulls `audio`
# (= `bevy_audio` + `vorbis`) — so a default bevy compiles rodio, a second cpal, a second ALSA
# binding and an ogg/vorbis decoder into a client that mixes through kira and has had bevy's
# `AudioPlugin` disabled at boot since 0530. This spells the same `default` out with `audio`
# taken away: `2d`/`3d`/`ui` decomposed into the parts they are made of, which is the whole
# union of the three minus that one entry (decision 1932 has the expansion, verified against a
# before/after feature tree). Nothing else in the graph asks for it — avian3d, bevy_egui and
# bevy_transform_interpolation all take bevy with `default-features = false`.
#
# If a future bevy needs another member, add it here by name; do not reach back for `2d`/`3d`/
# `ui`, which would quietly bring the audio stack with it.
bevy = { version = "0.18.1", default-features = false, features = [
"default_app",
"default_platform",
"2d_bevy_render",
"3d_bevy_render",
"ui_api",
"ui_bevy_render",
"scene",
"picking",
] }
# The render app's raw GPU handles (the WOW_GPU_MS meter's query set): bevy 0.18 does not wrap
# wgpu's QuerySet family, so the meter reaches for the same wgpu the workspace already builds.
wgpu = "27"
@@ -26,18 +46,24 @@ sha1 = "0.10.6"
# section stood empty for the project's whole life.
#
# The one entry below is not a fork of a WoW crate at all. `lua-src` compiles the Lua the client's
# addon VM runs on, and the 1.12 corpus needs one opcode that Lua 5.1 deleted — the iterator-less
# generic-for (`for k, v in someTable do`), which 183 of 218 corpus addons are reached by and which
# is the first session-start error for 60 of them. It is an OPCODE, so no layer above the VM can
# reach it: not a library function, not a global, not a metamethod.
# addon VM runs on, and benilla's Lua has to accept EXACTLY the grammar the 1.12.1 client's Lua
# accepts — no more and no less. Six hunks, all byte-read out of the client's own parser and VM:
# three restore what 5.1 removed (the iterator-less generic-for `for k, v in someTable do`, which
# 183 of 218 corpus addons are reached by; nesting long strings; the constructor's compat
# semicolon), three delete what 5.1 added (`...` as an expression, `#`, `%`). Both directions are
# below the reach of any layer above the VM — an opcode and a grammar are not a library function,
# a global, or a metamethod — and the corpus ASKS about the second direction directly: 170
# `loadstring` sites compile a vararg expression purely to find out which interpreter they are on.
#
# The fork is currently BYTE-IDENTICAL to upstream's 5.1.5 sources, and is landed that way on
# purpose: the pipeline is proven transparent before the behavioural payload goes into it, so that
# when the suite moves, the patch is the only thing that could have moved it. The payload waits on
# an RE answer about what the real client's own Lua does — stock Lua 5.0 is corroboration, not the
# authority. `third_party/lua-src/BENILLA.md` has the current state and the verifying command.
# The fork landed BYTE-IDENTICAL to upstream's 5.1.5 sources on purpose: the pipeline was proven
# transparent before any behavioural payload went into it, so when the suite moved, the patch was
# the only thing that could have moved it. `third_party/lua-src/BENILLA.md` has the current state,
# the per-hunk byte evidence and the verifying command (decisions 1215, 1315, 2101).
[patch.crates-io]
lua-src = { path = "third_party/lua-src" }
# kira 0.12.1 with one patch: spatial gains and the volume tween evaluated per CHUNK and
# interpolated, not per frame (decision 1945; `third_party/kira/BENILLA.md`).
kira = { path = "third_party/kira" }
# Optimize *dependencies* even in dev builds. avian/parry collision math (and wgpu/bevy render) is
# unusably slow at opt-level 0 — a plain `cargo run` crawls to <1 fps once physics is doing real work.
+1 -1
View File
@@ -24,7 +24,7 @@ impl CombinedAlphaMap {
/// encoding width and the 63→64 edge fix (vanilla: `false`, `true`).
pub fn new(chunk: &McnkChunk, has_big_alpha: bool, fix_alpha: bool) -> Self {
let mut map = vec![0u8; W * W * 4];
for px in map.chunks_exact_mut(4) {
for px in map.as_chunks_mut::<4>().0 {
px[3] = 255; // A unused, set opaque for tool visibility (matches wow-adt)
}
let mut s = Self {
+6 -4
View File
@@ -342,14 +342,16 @@ fn split_strings(blob: &[u8]) -> Vec<String> {
/// `name` tags the truncation error with the chunk this record list came from (MMID/MWID).
fn read_u32_list(data: &[u8], name: &'static str) -> Result<Vec<u32>> {
data.chunks_exact(4)
data.as_chunks::<4>()
.0
.iter()
.map(|c| c.u32_at(0).ok_or(Error::Truncated(name)))
.collect()
}
fn read_mddf(data: &[u8]) -> Result<Vec<DoodadPlacement>> {
let mut out = Vec::new();
for r in data.chunks_exact(36) {
for r in data.as_chunks::<36>().0 {
out.push(DoodadPlacement {
name_id: r.u32_at(0).ok_or(Error::Truncated("MDDF"))?,
unique_id: r.u32_at(4).ok_or(Error::Truncated("MDDF"))?,
@@ -372,7 +374,7 @@ fn read_mddf(data: &[u8]) -> Result<Vec<DoodadPlacement>> {
fn read_modf(data: &[u8]) -> Result<Vec<WmoPlacement>> {
let mut out = Vec::new();
for r in data.chunks_exact(64) {
for r in data.as_chunks::<64>().0 {
out.push(WmoPlacement {
name_id: r.u32_at(0).ok_or(Error::Truncated("MODF"))?,
unique_id: r.u32_at(4).ok_or(Error::Truncated("MODF"))?,
@@ -503,7 +505,7 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
.filter(|&(_, n)| n > 0)
{
let mut layers = Vec::new();
for l in slice(p, n).chunks_exact(16) {
for l in slice(p, n).as_chunks::<16>().0 {
layers.push(MclyLayer {
texture_id: l.u32_at(0).ok_or(Error::Truncated("MCLY"))?,
flags: MclyFlags {
+28 -3
View File
@@ -58,6 +58,11 @@ half = "2"
# macro plus a no-dependency runtime shim — it adds nothing to the graph at runtime, and it is the
# crate everyone uses for this. `assets/ui` is OUR content (MIT/Apache), not Blizzard's.
include_dir = "0.7"
# The 1.12 chat filters are PCRE over shipped DBCs (decision 2077; `text_filter`): 2289 profanity
# expressions and 28 spam ones, compiled once at startup and run over every chat line. `RegexSet` is
# exactly the shape that needs — one automaton over the whole list for the "can anything match at
# all" pass — and the crate is already in the tree transitively.
regex = "1"
# `getrusage` — whole-process CPU time (all threads), the perf probes' `cpu_ms`/`cpu_pct` metric
# (`perf::process_cpu_secs`). Wall-clock fps is unusable as a regression instrument on a machine
# running parallel sessions' builds; CPU-per-frame is, and it is also the number the Mac report
@@ -99,9 +104,11 @@ cosmic-text = "0.16"
# `fontdb` in the graph.
fontdb = "0.23"
# kira renders the mix; the device it plays through is OURS (`sound::output`, decision 1857) —
# kira's own cpal backend is not enabled, and cpal is out of the graph on our path with it: on
# macOS it could neither report a lost default device nor rebuild on one, and it discarded the
# IO-cycle timestamp the deadline meters need.
# kira's own `cpal` backend is not enabled on any target. On macOS that took cpal out of the graph
# entirely: it could neither report a lost default device nor rebuild on one, and it discarded the
# IO-cycle timestamp the deadline meters need. Off macOS cpal is back (1920) but as OUR device
# layer under the same backend, not as kira's — the ring, the render thread and the meters are the
# same code on every platform.
kira = { version = "0.12", default-features = false, features = ["mp3", "wav", "pcm", "adpcm"] }
# The realtime-audio allocation tripwire (decision 1857): `assert_no_alloc(|| …)` around the output
# IO callback and the render pass aborts a DEBUG build on any allocation inside them, so a
@@ -167,3 +174,21 @@ objc2-app-kit = { version = "0.2", features = [
"NSEvent",
] }
objc2-foundation = { version = "0.2", features = ["NSArray", "NSData", "NSGeometry"] }
# Everywhere else: the same device layer over cpal (decision 1920, `sound::output::cpal`). 1857
# owns the mix-ahead ring, the render thread and the meters on every platform; only the last
# hop — find the default device, open a stream on it, hand back one buffer per cycle — is
# platform code, and off macOS that hop is ALSA/PipeWire, WASAPI or the host's own API. cpal is
# the layer that already speaks all of them; on macOS it is still not in the graph, because
# there its defects (a no-op error callback on the default device, a pinned `DefaultOutput`
# unit, the discarded IO-cycle timestamp) are exactly what 1857 went direct to escape. On an
# unsupported target cpal compiles to its Null host, which reports no device — the same silent
# posture the old `stub.rs` had, without a second file to rot.
[target.'cfg(not(target_os = "macos"))'.dependencies]
cpal = "0.17"
# Windows: the journal's and the probes' CPU clocks (decision 2219, `perf::clock`) —
# `GetProcessTimes`, `GetThreadTimes` and `GetSystemTimes`, the twins of the `getrusage`,
# `clock_gettime` and `host_statistics64` arms. The version is one the graph already carries.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,163 +0,0 @@
<Ui>
<!--
Interface\FrameXML\BasicControls.xml, re-expressed — the reference's second file, right
after Fonts.xml, and one of the smallest in FrameXML. It carries four things an addon can
reach and we had none of:
TEXT(text) the identity function 1.12 wraps every displayed string in. **26 corpus
addons call it**, because half the shipped FrameXML does and they copied
the idiom. Missing, it is `attempt to call global 'TEXT'`.
message(text) the client's "show the player this" dialog. Its own popup, not a chat
line. Present in 64 corpus files — but read the line before ranking it
(1207): 55 of those are copies of `Dewdrop-2.0.lua`, and the genuine
callers are about ten addons.
_ERRORMESSAGE what `message` is implemented in terms of, and the reference's default
error handler. Two corpus addons replace it outright (ImprovedErrorFrame
is one), which they cannot do to a name that does not exist.
ScriptErrors the dialog both of the above put text into, plus DialogBoxFrame and the
DialogButton*Texture trio it is built from — three of which the corpus
also inherits from directly (`inherits_demand`, 2 addons each).
The three fonts this needs — GameFontRedLarge, DialogButtonNormalText,
DialogButtonHighlightText — are already in Fonts.xml, so this file is self-contained
against the one that loads before it, exactly as it is in the reference's own toc order.
-->
<Script><![CDATA[
-- ── _ERRORMESSAGE / message (ref BasicControls.xml l.5-15) ─────────────────────────────
--
-- COMPLETE, including `debuginfo()`. That line was guarded here when this file landed,
-- because `debuginfo` was an engine global we lacked and whose behaviour was uncarved —
-- and declaring a no-op under a real 1.12 name is exactly the "capability absent without a
-- failure" class 1203 named. wow-re has since carved it (2026-08-11): `0x7027f0` is
-- `33 c0 c3` — `xor eax,eax; ret`, three bytes, no call, no memory write. It genuinely
-- does nothing and returns nothing, and five of its `debug*` siblings are byte-identical.
-- So the no-op in `stdlib.rs` is the REFERENCE's no-op, the guard was measuring our own
-- ignorance rather than a real branch, and it is gone.
function _ERRORMESSAGE(msg)
debuginfo()
if not ScriptErrors:IsVisible() then
ScriptErrors_Message:SetText(msg)
ScriptErrors:Show()
end
return msg
end
function message(text)
_ERRORMESSAGE(text)
end
-- ── seterrorhandler(_ERRORMESSAGE): the reference's closing line, now transcribed ──────
-- This file withheld exactly this line from when it landed until decision 1305, because
-- the engine's error channel (`UiScript::errors()`) was the only place a Lua fault was
-- visible to any instrument and a modal would have blinded it. That trade is dissolved,
-- not decided differently: the engine now records every caught script error on the host
-- channel FIRST and then dispatches it to whatever `geterrorhandler()` answers
-- (`dispatch_script_errors_to_handler`, 1305) — so the harness's blocker tables read
-- exactly what they always read, and the player gets what the reference always gave them:
-- an addon that errors puts the red ScriptErrors dialog on screen instead of failing
-- somewhere only a terminal can see (B271's report was debugged entirely off WARN lines).
-- An addon that installs its OWN handler still wins outright, exactly as in 1195.
seterrorhandler(_ERRORMESSAGE)
-- ── TEXT (ref l.22-26, verbatim) ──────────────────────────────────────────────────────
-- "This function is used to translate text (actually it's already translated as-is)" is
-- the reference's own comment. It really is the identity function: 1.12 shipped one
-- localisation per client and the hook was never used.
function TEXT(text)
return text
end
-- ── UIParentLoadAddOn (ref UIParent.lua l.638-647) ────────────────────────────────────
-- The on-demand loader every "open the auction house / tradeskill / talent panel" path
-- goes through, and the one 5 corpus addons name — FuBar_MCPFu, Fubar_RareTrackerFu, MCP,
-- MikScrollingBattleText, SpecialTalent: five separate addons, not one library replicated
-- (1207/1240).
--
-- Every piece was already here and only the join was missing: `LoadAddOn` answers the
-- reference's own `(1, nil)` / `(nil, reason)` pair, `message` and `TEXT` are directly
-- above, and the `ADDON_*` reason words come from the real `GlobalStrings.lua` we RUN off
-- the patch chain rather than copy (1234).
--
-- **`FailedAddOnLoad` is the reference's own latch, and it is not decoration.** Without it
-- an addon that retries the load from a handler raises a modal on every attempt; with it
-- the failure shows once per addon name per session. It is exactly the detail this would
-- have lacked had it been reconstructed from the name instead of read.
--
-- Every reason our `LoadAddOn` can answer resolves to a string: MISSING, DISABLED,
-- NOT_DEMAND_LOADED, DEP_MISSING and DEP_DISABLED all have an `ADDON_..reason` word
-- (GlobalStrings l.16-31), the same reason set wow-re's `ui.md` l.2396 carves off
-- `0x51e780`. So the `getglobal` below cannot hand `format` a nil.
FailedAddOnLoad = {};
function UIParentLoadAddOn(name)
local loaded, reason = LoadAddOn(name)
if not loaded then
if not FailedAddOnLoad[name] then
message(format(TEXT(ADDON_LOAD_FAILED), name, TEXT(getglobal("ADDON_" .. reason))))
FailedAddOnLoad[name] = true
end
end
return loaded
end
]]></Script>
<!-- The dialog button faces (ref l.28-37). Same three-piece art as UIPanelButtonTemplate's,
with their own names because the reference's dialogs inherit these directly. -->
<Texture name="DialogButtonNormalTexture" file="Interface\Buttons\UI-Panel-Button-Up" virtual="true">
<TexCoords left="0" right="0.625" top="0" bottom="0.6875"/>
</Texture>
<Texture name="DialogButtonPushedTexture" file="Interface\Buttons\UI-Panel-Button-Down" virtual="true">
<TexCoords left="0" right="0.625" top="0" bottom="0.6875"/>
</Texture>
<Texture name="DialogButtonHighlightTexture" file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD" virtual="true">
<TexCoords left="0" right="0.625" top="0" bottom="0.6875"/>
</Texture>
<!-- DialogBoxFrame (ref l.38-77): a centered tooltip-bordered box with one OKAY button that
hides its parent. Virtual — ScriptErrors below is the only thing in shipped FrameXML that
inherits it, but two corpus addons inherit it too. -->
<Frame name="DialogBoxFrame" virtual="true" toplevel="true" frameStrata="DIALOG" hidden="true">
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<EdgeSize><AbsValue val="32"/></EdgeSize>
<TileSize><AbsValue val="32"/></TileSize>
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
</Backdrop>
<Frames>
<Button name="$parentButton">
<Size><AbsDimension x="128" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="16"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
this:GetParent():Hide()
</OnClick>
</Scripts>
<NormalTexture inherits="DialogButtonNormalTexture"/>
<PushedTexture inherits="DialogButtonPushedTexture"/>
<HighlightTexture inherits="DialogButtonHighlightTexture"/>
<ButtonText text="OKAY"/>
<NormalFont inherits="DialogButtonNormalText"/>
<HighlightFont inherits="DialogButtonHighlightText"/>
</Button>
</Frames>
</Frame>
<!-- "This is the general scripting error dialog" (ref l.79-100), verbatim. -->
<Frame name="ScriptErrors" inherits="DialogBoxFrame">
<Size><AbsDimension x="384" y="128"/></Size>
<Layers>
<Layer level="ARTWORK">
<FontString name="ScriptErrors_Message" inherits="GameFontRedLarge" justifyH="CENTER" justifyV="TOP" nonspacewrap="true">
<Size><AbsDimension x="360" y="0"/></Size>
<Anchors>
<Anchor point="TOP"><Offset><AbsDimension x="0" y="-16"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
</Ui>
@@ -1,69 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The innkeeper's "make this your home?" dialog (decision 1331): a registry entry on the
shared StaticPopup engine (UiPanels.xml) plus its UIParent event arm, written against the
patch-chain FrameXML (mpqx extraction, decision 0246) — StaticPopup.lua l.1308-1322 (the
entry) and UIParent.lua l.125 / l.547-548 (the registration and the show arm). The string
is GlobalStrings.lua l.840, verbatim.
It is NOT part of GossipFrame.xml, and the reason is the wire: selecting the innkeeper's
gossip line makes the server CLOSE the gossip menu and then ask. By the time this dialog
goes up the window that raised it is already gone, so it belongs with the other free-
standing confirms rather than with the panel.
The whole flow behind it is two packets — SMSG_BINDER_CONFIRM in, CMSG_BINDER_ACTIVATE out
— and only the second one binds anything (`crate::ui_binder`'s header carries the law).
Accepting is therefore the ONLY path to a moved hearthstone; there is no client-side bind
and nothing to undo on Cancel, which is why this entry has no OnCancel.
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim) ───────────────────────────────────────────────
local CONFIRM_BINDER = "Do you want to make %s your new home?" -- l.840
local ACCEPT = "Accept" -- l.10
local CANCEL = "Cancel" -- l.464
-- ── The confirm — answering to StaticPopup.lua l.1308-1322 ─────────────────────────────
-- This is the dialog's ONLY teardown, and it is a poll rather than an event: walk out of
-- the innkeeper's range and the question leaves the screen by itself, with no packet
-- either way. `CheckBinderDist()` is the engine binding behind it
-- (`benilla-ui/src/script/binder.rs`), false as soon as the app's NPC-session guard has
-- closed the question. Named rather than inlined into the entry, because "the question
-- expires when you walk away" is the interesting half of this file.
local function hideOnceOutOfRange(_, dialog)
if not CheckBinderDist() then
dialog:Hide()
end
end
StaticPopupDialogs["CONFIRM_BINDER"] = {
text = CONFIRM_BINDER,
button1 = ACCEPT,
button2 = CANCEL,
OnAccept = function()
ConfirmBinder()
end,
OnUpdate = hideOnceOutOfRange,
timeout = 0,
hideOnEscape = 1,
}
-- ── The UIParent event arm (UIParent.lua l.547-548) ────────────────────────────────────
-- arg1 is the area name the "%s" takes.
function BenillaBinderConfirmDriver_OnEvent()
if event == "CONFIRM_BINDER" then
StaticPopup_Show("CONFIRM_BINDER", arg1)
end
end
]]></Script>
<Frame name="BenillaBinderConfirmDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("CONFIRM_BINDER")
</OnLoad>
<OnEvent>BenillaBinderConfirmDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
-294
View File
@@ -1,294 +0,0 @@
<Ui>
<!--
The cast bar — benilla's own implementation of the 1.12 CastingBarFrame contract. The
extracted reference (Interface\FrameXML\CastingBarFrame.xml/.lua, mpqx; decision 0137
phase 1) is the SPECIFICATION: geometry, textures, colors, and every timing constant are
read off it, and the observable lifecycle below is the one it defines. The code is ours.
Driven by the SPELLCAST_* events crate::ui_cast fires off the wire (START name,ms / STOP /
FAILED / INTERRUPTED / DELAYED / CHANNEL_START ms,name / CHANNEL_UPDATE ms / CHANNEL_STOP).
The reference's globals (CastingBarText/Spark/Flash) are named regions, so they publish as
globals here exactly as they do there; `this` is the firing frame (RF-0025), and every
handler below reads it once into a named local rather than threading the global through a
dozen method calls. Deliberate departures from the specification's own .lua: its dead code
(the `if not this:IsVisible() then this:Hide() end` no-op guard at the head of the STOP
branch; the two write-only `this.mode` stores nothing reads), and the flash/fade steps
normalized to the reference tick rate (the CASTING_BAR_REF_TICK comment below —
decision 0454).
SPELLCAST_DELAYED is live: SMSG_SPELL_DELAYED (pushback on taking a hit) is decoded and fed,
so a hit slides the bar's window out instead of letting it finish early (decision 0256).
POSITION: the XML anchor y=55 below is only the pre-manage default, exactly as in the
reference — UIParent.lua overrides it on every bottom-stack layout pass
(UIPARENT_MANAGED_FRAME_POSITIONS["CastingBarFrame"]: baseY 60, +40 per either bottom
multibar, +40 per pet/stance bar, +9 per rep bar). Our transcription of that mechanism
lives in UIParent.xml (decision 0272); with the always-on multibars the bar sits at
y=100, and y=140 while the stance bar shows.
-->
<Script><![CDATA[
CASTING_BAR_ALPHA_STEP = 0.05
CASTING_BAR_FLASH_STEP = 0.2
CASTING_BAR_HOLD_TIME = 1
-- The ref applies the two steps PER OnUpdate TICK (no arg1 scaling) — the flash/fade
-- tail is frame-rate dependent by the reference's own design: ~25 ticks from hold-expiry
-- to hidden, i.e. 0.83 s at the reference bottle's configured cap (WoW/WTF/Config.wtf
-- `SET maxfps "30"`), 0.42 s at an era-typical 60 fps, a blink at 120. We render fast,
-- so the step lines below are normalized to the reference tick rate — the bar's
-- hold+fade then matches the reference window at any render rate (decision 0454).
CASTING_BAR_REF_TICK = 30
-- The 1.12 GlobalStrings values (no GlobalStrings.lua is loaded yet).
FAILED = FAILED or "Failed"
INTERRUPTED = INTERRUPTED or "Interrupted"
-- THE BAR'S WHOLE VOCABULARY, one entry per wire event, each taking the frame. OnLoad
-- registers this table's keys and OnEvent looks the incoming event up in it, so the set
-- the bar LISTENS for and the set it can ANSWER are the same set by construction — there
-- is no second list to fall out of step with. (The reference spells both halves out: eight
-- RegisterEvent calls, then a sixty-line if-chain testing the same eight strings back.)
local castingBarEvent = {}
-- Seat the fill at `value` and ride the spark on its leading edge. The bar is 195px wide,
-- so a fraction of `span` scales straight to a pixel offset from the frame's LEFT. A cast
-- and a channel differ only in which value and which span they hand in.
local function castingBarTrack(bar, value, span)
bar:SetValue(value)
CastingBarFlash:Hide()
local sparkPosition = ((value - bar.startTime) / span) * 195
-- Pushback slides startTime PAST the wall clock, so a cast really can sit before its
-- own window's start for a frame or two. (A channel cannot — its value counts down
-- from the far end and stops at startTime — so here the clamp is dead weight rather
-- than a change of behaviour.)
if sparkPosition < 0 then
sparkPosition = 0
end
CastingBarSpark:SetPoint("CENTER", CastingBarFrame, "LEFT", sparkPosition, 2)
end
-- A cast opens: orange, a full window anchored on the clock, the spark at the left edge.
castingBarEvent.SPELLCAST_START = function(bar)
bar:SetStatusBarColor(1.0, 0.7, 0.0)
CastingBarSpark:Show()
bar.startTime = GetTime()
bar.maxValue = bar.startTime + (arg2 / 1000)
bar:SetMinMaxValues(bar.startTime, bar.maxValue)
bar:SetValue(bar.startTime)
CastingBarText:SetText(arg1)
bar:SetAlpha(1.0)
bar.holdTime = 0
bar.casting = 1
bar.fadeOut = nil
bar:Show()
end
-- A channel opens. Its args are START's the other way round — milliseconds first, then the
-- name — and its fill DRAINS, so the bar is seated at the far end and walks back.
castingBarEvent.SPELLCAST_CHANNEL_START = function(bar)
bar:SetStatusBarColor(1.0, 0.7, 0.0)
CastingBarSpark:Show()
bar.maxValue = 1
bar.startTime = GetTime()
bar.endTime = bar.startTime + (arg1 / 1000)
bar.duration = arg1 / 1000
bar:SetMinMaxValues(bar.startTime, bar.endTime)
bar:SetValue(bar.endTime)
CastingBarText:SetText(arg2)
bar:SetAlpha(1.0)
bar.holdTime = 0
bar.casting = nil
bar.channeling = 1
bar.fadeOut = nil
bar:Show()
end
-- The server's mid-channel correction: the window keeps its LENGTH and is re-anchored to
-- end arg1 ms from now, so a pushback eats into what is left rather than extending it.
castingBarEvent.SPELLCAST_CHANNEL_UPDATE = function(bar)
if not bar:IsShown() then
return
end
local span = bar.endTime - bar.startTime
bar.endTime = GetTime() + (arg1 / 1000)
bar.startTime = bar.endTime - span
bar:SetMinMaxValues(bar.startTime, bar.endTime)
end
-- Pushback on taking a hit (SMSG_SPELL_DELAYED, decision 0256): BOTH ends of the window
-- slide out by arg1 ms, so the cast has that much longer to run and the spark jumps back.
-- A hit never cancels the bar.
castingBarEvent.SPELLCAST_DELAYED = function(bar)
if not bar:IsShown() then
return
end
local pushback = arg1 / 1000
bar.startTime = bar.startTime + pushback
bar.maxValue = bar.maxValue + pushback
bar:SetMinMaxValues(bar.startTime, bar.maxValue)
end
-- The cast landed, or a channel ran its course: green, full, and the completion bloom
-- armed at alpha 0 for OnUpdate to ramp. `flag` names the one field the event clears,
-- which is the only thing that separates the two of them.
local function castingBarFinished(bar, flag)
if not bar:IsShown() then
return
end
bar:SetValue(bar.maxValue)
bar:SetStatusBarColor(0.0, 1.0, 0.0)
CastingBarSpark:Hide()
CastingBarFlash:SetAlpha(0.0)
CastingBarFlash:Show()
bar[flag] = nil
bar.flash = 1
bar.fadeOut = 1
end
castingBarEvent.SPELLCAST_STOP = function(bar) castingBarFinished(bar, "casting") end
castingBarEvent.SPELLCAST_CHANNEL_STOP = function(bar) castingBarFinished(bar, "channeling") end
-- The cast did NOT land: red, the reason where the spell's name was, and held opaque for
-- CASTING_BAR_HOLD_TIME before the fade starts — no completion bloom. A channel is never
-- ended this way (the server closes one with CHANNEL_STOP), so a live channel ignores both.
local function castingBarAborted(bar, reason)
if not bar:IsShown() or bar.channeling then
return
end
bar:SetValue(bar.maxValue)
bar:SetStatusBarColor(1.0, 0.0, 0.0)
CastingBarSpark:Hide()
CastingBarText:SetText(reason)
bar.casting = nil
bar.fadeOut = 1
bar.holdTime = GetTime() + CASTING_BAR_HOLD_TIME
end
castingBarEvent.SPELLCAST_FAILED = function(bar) castingBarAborted(bar, FAILED) end
castingBarEvent.SPELLCAST_INTERRUPTED = function(bar) castingBarAborted(bar, INTERRUPTED) end
function CastingBarFrame_OnLoad()
local bar = this
for spellcast in pairs(castingBarEvent) do
bar:RegisterEvent(spellcast)
end
bar.casting = nil
bar.holdTime = 0
-- The alias global the reference publishes at the end of its own OnLoad
-- (`CastingBarFrame.lua:16`). `CastingBarFrame` IS the StatusBar in 1.12 — there is no
-- separate child frame — so the name an addon reaches for has to resolve to the frame
-- itself.
--
-- 11 corpus addons index `CastingBarFrameStatusBar`, and it was the largest single
-- name in the whole session-start `attempt to index global` row. It went missing here
-- once and nothing warned: a line dropped from INSIDE a function is invisible to every
-- instrument we have. `framexml-transcription-diff.py` compares elements and handlers,
-- not function bodies, and would not even pair these two files (ours is CastingBar.xml,
-- the reference's is CastingBarFrame.xml).
CastingBarFrameStatusBar = bar
bar:Hide()
end
function CastingBarFrame_OnEvent()
local handle = castingBarEvent[event]
if handle then
handle(this)
end
end
function CastingBarFrame_OnUpdate()
local bar = this
if bar.casting then
-- The fill IS the wall clock, pinned at the window's end once the cast is spent.
local now = GetTime()
if now > bar.maxValue then
now = bar.maxValue
end
castingBarTrack(bar, now, bar.maxValue - bar.startTime)
elseif bar.channeling then
local now = GetTime()
if now >= bar.endTime then
bar.channeling = nil
bar.fadeOut = 1
return
end
-- A channel drains: the value is the window's start plus what is LEFT of it, so
-- the fill empties from full instead of filling from empty.
castingBarTrack(bar, bar.startTime + (bar.endTime - now), bar.endTime - bar.startTime)
elseif GetTime() < bar.holdTime then
return
elseif bar.flash then
local alpha = CastingBarFlash:GetAlpha() + CASTING_BAR_FLASH_STEP * (arg1 * CASTING_BAR_REF_TICK)
if alpha < 1 then
CastingBarFlash:SetAlpha(alpha)
else
CastingBarFlash:SetAlpha(1.0)
bar.flash = nil
end
elseif bar.fadeOut then
local alpha = bar:GetAlpha() - CASTING_BAR_ALPHA_STEP * (arg1 * CASTING_BAR_REF_TICK)
if alpha > 0 then
bar:SetAlpha(alpha)
else
bar.fadeOut = nil
bar:Hide()
end
end
end
]]></Script>
<StatusBar name="CastingBarFrame" drawLayer="BORDER" toplevel="true" parent="UIParent" hidden="true">
<Size><AbsDimension x="195" y="13"/></Size>
<Anchors>
<Anchor point="BOTTOM">
<Offset><AbsDimension x="0" y="55"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture setAllPoints="true">
<Color r="0" g="0" b="0" a="0.5"/>
</Texture>
</Layer>
<Layer level="ARTWORK">
<FontString name="CastingBarText" inherits="GameFontHighlight">
<Size><AbsDimension x="185" y="16"/></Size>
<Anchors>
<Anchor point="TOP">
<Offset><AbsDimension x="0" y="5"/></Offset>
</Anchor>
</Anchors>
</FontString>
<Texture name="CastingBarBorder" file="Interface\CastingBar\UI-CastingBar-Border">
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors>
<Anchor point="TOP">
<Offset><AbsDimension x="0" y="28"/></Offset>
</Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<Texture name="CastingBarSpark" file="Interface\CastingBar\UI-CastingBar-Spark" alphaMode="ADD">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
</Texture>
<Texture name="CastingBarFlash" file="Interface\CastingBar\UI-CastingBar-Flash" alphaMode="ADD">
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors>
<Anchor point="TOP">
<Offset><AbsDimension x="0" y="28"/></Offset>
</Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnLoad>CastingBarFrame_OnLoad()</OnLoad>
<OnEvent>CastingBarFrame_OnEvent()</OnEvent>
<OnUpdate>CastingBarFrame_OnUpdate()</OnUpdate>
</Scripts>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="1.0" g="0.7" b="0.0"/>
</StatusBar>
</Ui>
File diff suppressed because it is too large Load Diff
-723
View File
@@ -1,723 +0,0 @@
<Ui>
<!--
The center-screen scrolling combat text (decision 0578) — the 1.11+ "Floating Combat Text"
option, written here in our own terms. The shipped load-on-demand addon
`Blizzard_CombatText` (patch.MPQ `Interface\AddOns\Blizzard_CombatText\`, extracted to
WoW/_extracted_addons/Blizzard_CombatText/) is the SPECIFICATION, and what it fixes is
contract: the message-type keys, the option family, the scroll/fade/crit envelope and
every number in them are all addressed by name from addons, so they are reproduced exactly
while the code that carries them is ours. The picture: screen-space FontStrings scrolling
up from UIParent-bottom +384 (≈ over the character), driven by the engine events this
frame registers. Where our behaviour departs from the spec, it is named:
- The ref is load-on-demand (`UIParentLoadAddOn` when SHOW_COMBAT_TEXT == "1"); we load
always and let CombatText_UpdateDisplayedMessages gate registration, ref-identically.
- SHOW_COMBAT_TEXT defaulted "1" here against the ref's "0" from 0578 (the director's ask
was the enabled experience) until 1804 put the whole option family back on the
reference's own values. All fourteen now sit at the spec's defaults
(UIOptionsFrame.lua l.69-84), and the Combat page's checkbox is where it turns on.
- The ref's virtual FontString template (CombatTextTemplate) expands to 20 identical
strings; region templates are outside the v1 loader's object model (BuffFrame.xml has
the precedent note), so the 20 instances inherit the CombatTextFont Font object and
carry the template's anchor directly.
- UNIT_MANA, the spec's own event. This used to read UNIT_POWER_UPDATE + the Era Unit* API
(0068's surface); 1819 retired that pair — 1.12 names the resource in the event, and the
Era spelling was a deviation this file's own header already flagged. Same low-mana law,
and now the handler is not woken by rage or energy at all. PLAYER_COMBO_POINTS rides the combo wire (0869); its arm reads
`GetComboPoints()`, so it is rogue/druid-only and per-target for free — the binding
gates, not this file (0875).
- Lua 5.0 `for k,v in t` iteration becomes explicit index loops (5.1 VM), backwards
where the body removes.
- COMBAT_TEXT_TYPE_INFO is grown from a compact spec (colour · gate · flags · the types
that share them) walked by the loop under it, instead of one literal per row: the same
facts with the repetition taken out. The table it produces IS the contract — the same
keys with the same values, and one fresh entry table per key, because `show` is
rewritten per entry and an addon may edit any of them.
- The two low-resource warnings, the two flow directions in CombatText_AddMessage, and the
"park a string back at the seat" step all appear once here; the spec writes each of them
out twice.
The wire → messageType mapping feeding COMBAT_TEXT_UPDATE lives app-side
(net/apply/combat_log.rs + ui_unit.rs) and is PROVISIONAL pending the dispatched wow-re
emission pin (decision 0578).
-->
<Script><![CDATA[
-- ── The pool ────────────────────────────────────────────────────────────────────────────
-- One screen line per pool string, and the list of the ones currently in flight. The pool
-- is fixed: a message arriving with all twenty busy evicts the oldest.
NUM_COMBAT_TEXT_LINES = 20
COMBAT_TEXT_TO_ANIMATE = {}
-- ── Where a line sits ───────────────────────────────────────────────────────────────────
-- COMBAT_TEXT_LOCATIONS is the seat and the target the current float mode implies; it is
-- filled in by CombatText_UpdateDisplayedMessages, not here. A new line is pushed clear of
-- everything already in flight by one line plus COMBAT_TEXT_SPACING, but the stack never
-- runs further than COMBAT_TEXT_MAX_OFFSET from the seat — past that it starts over. The
-- staggered types jitter sideways by up to half COMBAT_TEXT_STAGGER_RANGE either way, so a
-- flurry of hits does not print as one column.
COMBAT_TEXT_LOCATIONS = {}
COMBAT_TEXT_SPACING = 10
COMBAT_TEXT_MAX_OFFSET = 130
COMBAT_TEXT_STAGGER_RANGE = 20
-- ── How long a line lives ───────────────────────────────────────────────────────────────
-- It travels for COMBAT_TEXT_SCROLLSPEED seconds; COMBAT_TEXT_FADEOUT_TIME is when the
-- fade STARTS, not how long it takes, so the fade is the tail of the same window.
COMBAT_TEXT_SCROLLSPEED = 1.9
COMBAT_TEXT_FADEOUT_TIME = 1.3
-- ── How big it is drawn ─────────────────────────────────────────────────────────────────
-- An ordinary line is COMBAT_TEXT_HEIGHT and stays there. A crit "pops": seeded at the
-- minimum, grown to the maximum across CRIT_SCALE_TIME, and back down to ordinary size by
-- CRIT_SHRINKTIME (CombatText_OnUpdate walks that envelope).
COMBAT_TEXT_HEIGHT = 25
COMBAT_TEXT_CRIT_MINHEIGHT = 30
COMBAT_TEXT_CRIT_MAXHEIGHT = 60
COMBAT_TEXT_CRIT_SCALE_TIME = 0.05
COMBAT_TEXT_CRIT_SHRINKTIME = 0.2
-- ── When the low-resource warnings latch ────────────────────────────────────────────────
-- Fractions of the bar, tested on the way down; see CombatText_LowResourceWarning.
COMBAT_TEXT_LOW_HEALTH_THRESHOLD = 0.2
COMBAT_TEXT_LOW_MANA_THRESHOLD = 0.2
-- The option family: every saved global this feature owns, next to the value it ships
-- with. **All fourteen are the spec's own defaults** (UIOptionsFrame.lua l.69-84), the
-- master SHOW_COMBAT_TEXT included — it shipped "1" against the spec's "0" from 0578 to
-- 1804 (see the header). Held to that by
-- `reference_ui::tests::our_saved_ui_globals_default_to_the_references_own_values`, which
-- reads the reference's file off the player's chain rather than trusting this comment.
--
-- Each name is assigned AT FILE SCOPE and registered for saving in the same pass, because
-- the load order is what makes a saved value stick (decision 1128): defaults first, the
-- saved chunk over the top, consumers third; reverse any two and a saved value can never
-- win. The Options window's Combat page drives all fourteen (decision 1134) and takes its
-- "Defaults" from these very globals, read at each row's OnLoad — which runs before the
-- saved chunk — so a default lives in exactly one place, this table, and the panel cannot
-- drift from it.
local combatTextOptions = {
{"SHOW_COMBAT_TEXT", "0"},
{"COMBAT_TEXT_SHOW_LOW_HEALTH_MANA", "1"},
{"COMBAT_TEXT_SHOW_AURAS", "1"},
{"COMBAT_TEXT_SHOW_AURA_FADE", "0"},
{"COMBAT_TEXT_SHOW_COMBAT_STATE", "1"},
{"COMBAT_TEXT_SHOW_DODGE_PARRY_MISS", "0"},
{"COMBAT_TEXT_SHOW_RESISTANCES", "0"},
{"COMBAT_TEXT_SHOW_REPUTATION", "0"},
{"COMBAT_TEXT_SHOW_REACTIVES", "0"},
{"COMBAT_TEXT_SHOW_FRIENDLY_NAMES", "0"},
{"COMBAT_TEXT_SHOW_COMBO_POINTS", "0"},
{"COMBAT_TEXT_SHOW_MANA", "0"},
{"COMBAT_TEXT_SHOW_HONOR_GAINED", "1"},
{"COMBAT_TEXT_FLOAT_MODE", "1"},
}
for i = 1, getn(combatTextOptions) do
local name, default = combatTextOptions[i][1], combatTextOptions[i][2]
setglobal(name, default)
RegisterForSave(name)
end
-- The message words (shipped enUS GlobalStrings values, hardcoded like the rest of our
-- enUS-only data; or-guarded so an earlier definition wins).
MANA = MANA or "Mana"
RAGE = RAGE or "Rage"
FOCUS = FOCUS or "Focus"
ENERGY = ENERGY or "Energy"
HEALTH_LOW = HEALTH_LOW or "Health Low"
MANA_LOW = MANA_LOW or "Mana Low"
ENTERING_COMBAT = ENTERING_COMBAT or "Entering Combat"
LEAVING_COMBAT = LEAVING_COMBAT or "Leaving Combat"
BLOCK = BLOCK or "Block"
ABSORB = ABSORB or "Absorb"
RESIST = RESIST or "Resist"
SPELL_MISSED = SPELL_MISSED or "Miss"
SPELL_DODGED = SPELL_DODGED or "Dodge"
SPELL_PARRIED = SPELL_PARRIED or "Parry"
SPELL_BLOCKED = SPELL_BLOCKED or "Block"
SPELL_EVADED = SPELL_EVADED or "Evade"
SPELL_IMMUNE = SPELL_IMMUNE or "Immune"
SPELL_DEFLECTED = SPELL_DEFLECTED or "Deflect"
SPELL_REFLECTED = SPELL_REFLECTED or "Reflect"
SPELL_RESISTED = SPELL_RESISTED or "Resist"
SPELL_ABSORBED = SPELL_ABSORBED or "Absorb"
AURA_END = AURA_END or "<%s> fades"
BLOCK_TRAILER = BLOCK_TRAILER or " (%d blocked)"
ABSORB_TRAILER = ABSORB_TRAILER or " (%d absorbed)"
RESIST_TRAILER = RESIST_TRAILER or " (%d resisted)"
COMBAT_TEXT_HONOR_GAINED = COMBAT_TEXT_HONOR_GAINED or "Honor %s"
--[[
COMBAT_TEXT_TYPE_INFO — one entry per message type, and the four things an entry says:
r, g, b the colour the line prints in, as 0..1 floats
show 1 while this type is currently being printed, nil while it is not.
CombatText_UpdateDisplayedMessages rewrites it for every gated type from
the option `var` names, so an entry carrying `var` and no `show` has
merely not been refreshed yet, while an entry carrying `show` and no
`var` is on for as long as the feature itself is
isStaggered 1 to jitter the line sideways instead of printing it on the centre line
var the name of the saved global that gates this type; nil = ungated
This table is API: addons index it by message-type key and read and write these fields, so
the keys and the values are exactly the specification's. The rows below are OURS — almost
every type shares its colour and its gate with several others, so the facts are written
once per group and the loop underneath expands them into one entry per type. Fresh table
per entry, never a shared one: `show` is rewritten per entry, and an addon may edit any
single entry without that leaking into its neighbours.
]]
-- The six colours the whole table draws from.
local CT_WHITE = {1, 1, 1}
local CT_RED = {1, 0.1, 0.1}
local CT_GREEN = {0.1, 1, 0.1}
local CT_BLUE = {0.1, 0.1, 1}
local CT_VIOLET = {0.79, 0.3, 0.85}
local CT_GOLD = {1, 0.82, 0}
-- Colour · gate · flags · the message types that share them. `always` is the `show` an
-- ungated type is born with; `jitter` is `isStaggered`; `gate` is `var`.
local combatTextSpec = {
-- Always on. Only the master toggle takes these away.
{colour = CT_RED, always = 1, jitter = 1, types = {"DAMAGE"}},
{colour = CT_RED, always = 1, types = {"DAMAGE_CRIT"}},
{colour = CT_VIOLET, always = 1, types = {"SPELL_DAMAGE"}},
{colour = CT_WHITE, always = 1, types = {"SPLIT_DAMAGE"}},
{colour = CT_GREEN, always = 1, types = {"HEAL", "HEAL_CRIT", "PERIODIC_HEAL", "SPELL_CAST"}},
-- Never on by themselves: no `always`, no gate, so `show` is nil and stays nil. They
-- carry a colour so that anything which does switch one on has one ready.
{colour = CT_WHITE, types = {"INTERRUPT", "ENCHANTMENT_ADDED", "ENCHANTMENT_REMOVED",
"DISPEL_FAILED", "SPELL_CAST_START", "SPELL_DISPELLED", "DAMAGE_SHIELD",
"EXTRA_ATTACKS"}},
-- "Show dodge/parry/miss": swings that went nowhere, melee in red and spells in white.
-- The melee whiffs jitter because several can land in the same instant.
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS", jitter = 1,
types = {"MISS", "DODGE", "PARRY", "EVADE"}},
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS",
types = {"IMMUNE", "DEFLECT"}},
{colour = CT_WHITE, gate = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS",
types = {"SPELL_MISSED", "SPELL_DODGED", "SPELL_PARRIED", "SPELL_EVADED",
"SPELL_IMMUNE", "SPELL_DEFLECTED", "SPELL_REFLECTED"}},
-- "Show resistances": a hit that landed but was partly eaten.
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_RESISTANCES",
types = {"BLOCK", "ABSORB", "RESIST", "PROC_RESISTED"}},
{colour = CT_WHITE, gate = "COMBAT_TEXT_SHOW_RESISTANCES", types = {"SPELL_BLOCKED"}},
{colour = CT_VIOLET, gate = "COMBAT_TEXT_SHOW_RESISTANCES",
types = {"SPELL_ABSORBED", "SPELL_RESISTED"}},
-- The aura pair — what landed, and what wore off — each split by whose side it helps.
{colour = CT_GREEN, gate = "COMBAT_TEXT_SHOW_AURAS", types = {"AURA_START"}},
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_AURAS", types = {"AURA_START_HARMFUL"}},
{colour = CT_GREEN, gate = "COMBAT_TEXT_SHOW_AURA_FADE", types = {"AURA_END"}},
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_AURA_FADE", types = {"AURA_END_HARMFUL"}},
-- Resources and standing: everything you gained rather than took.
{colour = CT_BLUE, gate = "COMBAT_TEXT_SHOW_MANA",
types = {"MANA", "RAGE", "FOCUS", "ENERGY"}},
{colour = CT_BLUE, gate = "COMBAT_TEXT_SHOW_COMBO_POINTS", types = {"COMBO_POINTS"}},
{colour = CT_BLUE, gate = "COMBAT_TEXT_SHOW_REPUTATION", types = {"FACTION"}},
{colour = CT_BLUE, gate = "COMBAT_TEXT_SHOW_HONOR_GAINED", types = {"HONOR_GAINED"}},
-- The state warnings, each on its own switch.
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_LOW_HEALTH_MANA",
types = {"HEALTH_LOW", "MANA_LOW"}},
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_COMBAT_STATE",
types = {"ENTERING_COMBAT", "LEAVING_COMBAT"}},
{colour = CT_GOLD, gate = "COMBAT_TEXT_SHOW_REACTIVES", types = {"SPELL_ACTIVE"}},
}
COMBAT_TEXT_TYPE_INFO = {}
for i = 1, getn(combatTextSpec) do
local row = combatTextSpec[i]
for j = 1, getn(row.types) do
COMBAT_TEXT_TYPE_INFO[row.types[j]] = {
r = row.colour[1],
g = row.colour[2],
b = row.colour[3],
show = row.always,
isStaggered = row.jitter,
var = row.gate,
}
end
end
-- A latched low-resource warning. The line prints on the crossing INTO the danger band and
-- not again until the bar has climbed back out of it — otherwise every tick of a fight
-- spent under 20% would print another one. `flag` names the field on the CombatText frame
-- that remembers we already warned; `low` is the caller's verdict, because "low" is not
-- the same question for the two bars (mana additionally requires the bar to BE mana: a
-- rage or energy user is never low on mana). Returns the message type to print, or nil.
local function CombatText_LowResourceWarning(flag, low, messageType)
if not low then
CombatText[flag] = nil
return nil
end
if CombatText[flag] then
return nil
end
CombatText[flag] = 1
return messageType
end
-- The engine events whose entire meaning is "print this message type". Everything else
-- either carries its own type in arg1 (COMBAT_TEXT_UPDATE) or IS its own type.
local combatTextStateMessage = {
PLAYER_REGEN_DISABLED = "ENTERING_COMBAT",
PLAYER_REGEN_ENABLED = "LEAVING_COMBAT",
}
function CombatText_OnLoad()
CombatText_UpdateDisplayedMessages()
CombatText.previousMana = {}
end
function CombatText_OnEvent(event)
-- What we are about to print: the type (which picks the colour and the gating), the
-- payload it formats from, the finished line, and the optional "crit" presentation.
local messageType, message
local data = arg2
local displayType
if event == "UNIT_HEALTH" then
if arg1 == "player" then
local low = UnitHealth("player") / UnitHealthMax("player") <= COMBAT_TEXT_LOW_HEALTH_THRESHOLD
messageType = CombatText_LowResourceWarning("lowHealth", low, "HEALTH_LOW")
end
if not messageType then
return
end
elseif event == "UNIT_MANA" then
-- The powerType == 0 guard still earns its place: `power_token` reports an UNKNOWN
-- power type as MANA (its descriptor default), so this event can arrive for a unit
-- whose track is not really mana. Rage, focus and energy now have their own event
-- names and never reach here at all (1819).
local mana = UnitMana("player")
local powerType = UnitPowerType("player")
if arg1 == "player" then
local low = mana / UnitManaMax("player") <= COMBAT_TEXT_LOW_MANA_THRESHOLD and powerType == 0
messageType = CombatText_LowResourceWarning("lowMana", low, "MANA_LOW")
end
if not messageType then
return
end
elseif event == "PLAYER_COMBO_POINTS" then
-- The count itself is the payload, and a full bar is worth a crit-sized line.
-- Spending them fires this too, with nothing left to announce.
data = GetComboPoints()
if data <= 0 then
return
end
messageType = "COMBO_POINTS"
if data == MAX_COMBO_POINTS then
displayType = "crit"
end
elseif event == "COMBAT_TEXT_UPDATE" then
messageType = arg1
else
messageType = combatTextStateMessage[event] or event
end
-- The type decides the colour and whether this is printed at all. An unknown type
-- is white and, having no `show`, prints nothing — which is how a message type we do
-- not know about stays silent instead of erroring.
local info = COMBAT_TEXT_TYPE_INFO[messageType]
if not info then
info = {r = 1, g = 1, b = 1}
end
if not info.show then
return
end
local isStaggered = info.isStaggered
if messageType == "" then
message = nil
elseif messageType == "DAMAGE_CRIT" then
displayType = "crit"
message = "-" .. data
elseif messageType == "DAMAGE" or messageType == "SPELL_DAMAGE" then
message = "-" .. data
elseif messageType == "SPELL_CAST" or messageType == "AURA_START" or messageType == "AURA_START_HARMFUL" then
message = "<" .. data .. ">"
elseif messageType == "AURA_END" or messageType == "AURA_END_HARMFUL" then
message = format(AURA_END, data)
elseif messageType == "HEAL" or messageType == "PERIODIC_HEAL" then
if COMBAT_TEXT_SHOW_FRIENDLY_NAMES == "1" and messageType == "HEAL" and UnitName("player") ~= data then
message = "+" .. arg3 .. " [" .. data .. "]"
else
message = "+" .. arg3
end
elseif messageType == "HEAL_CRIT" then
displayType = "crit"
if COMBAT_TEXT_SHOW_FRIENDLY_NAMES == "1" and UnitName("player") ~= data then
message = "+" .. arg3 .. " [" .. data .. "]"
else
message = "+" .. arg3
end
elseif messageType == "MANA" then
if tonumber(data) > 0 then
data = "+" .. data
end
message = data .. " " .. MANA
elseif messageType == "RAGE" then
message = "+" .. data .. " " .. RAGE
elseif messageType == "FOCUS" then
message = "+" .. data .. " " .. FOCUS
elseif messageType == "ENERGY" then
message = "+" .. data .. " " .. ENERGY
elseif messageType == "FACTION" then
if tonumber(arg3) > 0 then
arg3 = "+" .. arg3
end
message = "(" .. data .. " " .. arg3 .. ")"
elseif messageType == "BLOCK" then
if arg3 then
message = data .. " " .. format(BLOCK_TRAILER, arg3)
else
message = BLOCK
end
elseif messageType == "ABSORB" then
if arg3 then
message = arg2 .. " " .. format(ABSORB_TRAILER, arg3)
else
message = ABSORB
end
elseif messageType == "RESIST" or messageType == "SPELL_RESISTED" then
if arg3 then
message = data .. " " .. format(RESIST_TRAILER, arg3)
else
message = RESIST
end
elseif messageType == "HONOR_GAINED" then
if tonumber(data) > 0 then
data = "+" .. data
end
message = format(COMBAT_TEXT_HONOR_GAINED, data)
elseif messageType == "SPELL_ACTIVE" then
displayType = "crit"
message = "<" .. data .. ">"
elseif messageType == "COMBO_POINTS" then
message = data .. " Combo Points"
else
message = getglobal(messageType)
if not message then
message = getglobal("COMBAT_TEXT_" .. messageType)
end
end
-- A branch that produced no line (the empty type, or a global-string lookup that
-- came back nil) simply prints nothing.
if message then
CombatText_AddMessage(message, COMBAT_TEXT_SCROLL_FUNCTION, info.r, info.g, info.b, displayType, isStaggered)
end
end
function CombatText_OnUpdate(elapsed)
-- Backwards, so the removal inside the body can't skip a neighbor (the ref's 5.0
-- `for in` + tremove tolerated it; a 5.1 index loop must not).
for i = getn(COMBAT_TEXT_TO_ANIMATE), 1, -1 do
local value = COMBAT_TEXT_TO_ANIMATE[i]
if value.scrollTime >= COMBAT_TEXT_SCROLLSPEED then
CombatText_RemoveMessage(value)
else
value.scrollTime = value.scrollTime + elapsed
local xPos, yPos = value.scrollFunction(value)
value.yPos = yPos
value:SetPoint("TOP", UIParent, "BOTTOM", xPos, yPos)
if value.scrollTime >= COMBAT_TEXT_FADEOUT_TIME then
local alpha = 1 - ((value.scrollTime - COMBAT_TEXT_FADEOUT_TIME) / (COMBAT_TEXT_SCROLLSPEED - COMBAT_TEXT_FADEOUT_TIME))
value:SetAlpha(max(alpha, 0))
end
-- The crit pop: up to the maximum inside the scale window, back down by the
-- shrink time, then it stops being a crit and holds its size.
if value.isCrit then
if value.scrollTime <= COMBAT_TEXT_CRIT_SCALE_TIME then
value:SetTextHeight(floor(COMBAT_TEXT_CRIT_MINHEIGHT + ((COMBAT_TEXT_CRIT_MAXHEIGHT - COMBAT_TEXT_CRIT_MINHEIGHT) * value.scrollTime / COMBAT_TEXT_CRIT_SCALE_TIME)))
elseif value.scrollTime <= COMBAT_TEXT_CRIT_SHRINKTIME then
value:SetTextHeight(floor(COMBAT_TEXT_CRIT_MAXHEIGHT - ((COMBAT_TEXT_CRIT_MAXHEIGHT - COMBAT_TEXT_CRIT_MINHEIGHT) * (value.scrollTime - COMBAT_TEXT_CRIT_SCALE_TIME) / (COMBAT_TEXT_CRIT_SHRINKTIME - COMBAT_TEXT_CRIT_SCALE_TIME))))
else
value.isCrit = nil
end
end
end
end
end
-- A string that is not in flight: invisible, hidden, and back on the seat so the next
-- caller measures it from a known place. Both ways out of the animation list end here.
local function CombatText_ParkString(string)
string:SetAlpha(0)
string:Hide()
string:SetPoint("TOP", UIParent, "BOTTOM", COMBAT_TEXT_LOCATIONS.startX, COMBAT_TEXT_LOCATIONS.startY)
end
function CombatText_AddMessage(message, scrollFunction, r, g, b, displayType, isStaggered)
local string, noStringsAvailable = CombatText_GetAvailableString()
if noStringsAvailable then
return
end
string:SetText(message)
string:SetTextColor(r, g, b)
string.scrollTime = 0
-- Where the line is seated. `flow` is the direction the STACK grows — back down the
-- way the run came, so -1 when the mode floats upward and +1 when it floats down —
-- and having it as a number is what lets one walk serve both modes. Start at the
-- string's own parked edge, then push clear of every line already in flight by one
-- 16-unit line plus COMBAT_TEXT_SPACING; if that has carried the stack further than
-- COMBAT_TEXT_MAX_OFFSET from the seat, start the column over at the parked edge
-- instead of marching off the screen. (`GetBottom`/`GetTop` are nil before the first
-- layout resolve — the parked seat stands in; identical after frame 1.)
local flow, parkedY
if COMBAT_TEXT_LOCATIONS.startY < COMBAT_TEXT_LOCATIONS.endY then
flow = -1
parkedY = string:GetBottom()
else
flow = 1
parkedY = string:GetTop()
end
parkedY = parkedY or COMBAT_TEXT_LOCATIONS.startY
local seatY = parkedY
for i = 1, getn(COMBAT_TEXT_TO_ANIMATE) do
local clearOf = COMBAT_TEXT_TO_ANIMATE[i].yPos + flow * (16 + COMBAT_TEXT_SPACING)
if (clearOf - seatY) * flow >= 0 then
seatY = clearOf
end
end
if (seatY - COMBAT_TEXT_LOCATIONS.startY) * flow > COMBAT_TEXT_MAX_OFFSET then
seatY = parkedY
end
-- Everything `displayType` decides, in one place. A crit pops through its own scale
-- envelope and always takes the straight scroll (an arc would fight the pop); a crit or
-- a sticky ends where it began, so it holds the seat instead of travelling; anything
-- else takes the caller's scroll and runs the whole way.
if displayType == "crit" then
string.scrollFunction = CombatText_StandardScroll
string.isCrit = 1
string.endY = COMBAT_TEXT_LOCATIONS.startY
string:SetTextHeight(COMBAT_TEXT_CRIT_MINHEIGHT)
else
string.scrollFunction = scrollFunction
string:SetTextHeight(COMBAT_TEXT_HEIGHT)
if displayType == "sticky" then
string.endY = COMBAT_TEXT_LOCATIONS.startY
else
string.endY = COMBAT_TEXT_LOCATIONS.endY
end
end
-- The fountain arcs alternate sides, so consecutive lines do not trace one path.
if CombatText.xDir == 1 then
CombatText.xDir = -1
else
CombatText.xDir = 1
end
string.xDir = CombatText.xDir
-- Staggered types jitter up to half COMBAT_TEXT_STAGGER_RANGE either side of centre.
string.startX = COMBAT_TEXT_LOCATIONS.startX
if isStaggered then
string.startX = string.startX + random(0, COMBAT_TEXT_STAGGER_RANGE) - COMBAT_TEXT_STAGGER_RANGE / 2
end
string.startY = seatY
string.yPos = seatY
string:SetPoint("TOP", UIParent, "BOTTOM", string.startX, seatY)
string:SetAlpha(1)
string:Show()
tinsert(COMBAT_TEXT_TO_ANIMATE, string)
end
function CombatText_RemoveMessage(string)
for i = 1, getn(COMBAT_TEXT_TO_ANIMATE) do
if COMBAT_TEXT_TO_ANIMATE[i] == string then
tremove(COMBAT_TEXT_TO_ANIMATE, i)
CombatText_ParkString(string)
break
end
end
end
function CombatText_GetAvailableString()
local string
for i = 1, NUM_COMBAT_TEXT_LINES do
string = getglobal("CombatText" .. i)
if not string:IsShown() then
return string
end
end
return CombatText_GetOldestString(), 1
end
function CombatText_GetOldestString()
local oldestString = COMBAT_TEXT_TO_ANIMATE[1]
CombatText_RemoveMessage(oldestString)
return oldestString
end
function CombatText_ClearAnimationList()
COMBAT_TEXT_TO_ANIMATE = {}
for i = 1, NUM_COMBAT_TEXT_LINES do
CombatText_ParkString(getglobal("CombatText" .. i))
end
end
function CombatText_UpdateDisplayedMessages()
-- The master toggle is enforced at the source: with the feature off we stop
-- listening entirely rather than filtering every message as it arrives.
if SHOW_COMBAT_TEXT == "0" then
CombatText:UnregisterEvent("COMBAT_TEXT_UPDATE")
CombatText:UnregisterEvent("UNIT_HEALTH")
CombatText:UnregisterEvent("UNIT_MANA")
CombatText:UnregisterEvent("PLAYER_COMBO_POINTS")
CombatText:UnregisterEvent("PLAYER_REGEN_DISABLED")
CombatText:UnregisterEvent("PLAYER_REGEN_ENABLED")
return
else
CombatText:RegisterEvent("COMBAT_TEXT_UPDATE")
CombatText:RegisterEvent("UNIT_HEALTH")
CombatText:RegisterEvent("UNIT_MANA")
CombatText:RegisterEvent("PLAYER_COMBO_POINTS")
CombatText:RegisterEvent("PLAYER_REGEN_DISABLED")
CombatText:RegisterEvent("PLAYER_REGEN_ENABLED")
end
-- Re-read every gated type's option into its `show`. This is the only writer of
-- `show` for a type that carries a `var`, so it runs on every option change.
for messageType, value in pairs(COMBAT_TEXT_TYPE_INFO) do
if value.var then
if getglobal(value.var) == "1" then
value.show = 1
else
value.show = nil
end
end
end
-- The engine's UI space is the 768-virtual coordinate system itself (decision
-- 0582), so the ref constants stand verbatim (0581's px shim retired). The ref's
-- uiScale-cvar division joins when a cvar system exists.
local uiScale = 1
-- The float mode picks the path and the run: modes 1 and 3 rise to 609 (3 on an
-- arc), mode 2 falls to 159. All three start at the same seat.
if COMBAT_TEXT_FLOAT_MODE == "1" then
COMBAT_TEXT_SCROLL_FUNCTION = CombatText_StandardScroll
COMBAT_TEXT_LOCATIONS = {
startX = 0,
startY = 384 / uiScale,
endX = 0,
endY = 609 / uiScale
}
elseif COMBAT_TEXT_FLOAT_MODE == "2" then
COMBAT_TEXT_SCROLL_FUNCTION = CombatText_StandardScroll
COMBAT_TEXT_LOCATIONS = {
startX = 0,
startY = 384 / uiScale,
endX = 0,
endY = 159 / uiScale
}
else
COMBAT_TEXT_SCROLL_FUNCTION = CombatText_FountainScroll
COMBAT_TEXT_LOCATIONS = {
startX = 0,
startY = 384 / uiScale,
endX = 0,
endY = 609 / uiScale
}
end
CombatText_ClearAnimationList()
end
function CombatText_StandardScroll(value)
local xPos = value.startX + ((COMBAT_TEXT_LOCATIONS.endX - COMBAT_TEXT_LOCATIONS.startX) * value.scrollTime / COMBAT_TEXT_SCROLLSPEED)
local yPos = value.startY + ((value.endY - COMBAT_TEXT_LOCATIONS.startY) * value.scrollTime / COMBAT_TEXT_SCROLLSPEED)
return xPos, yPos
end
function CombatText_FountainScroll(value)
local radius = 150
local xPos = value.xDir * (value.startX + radius * cos(90 * value.scrollTime / COMBAT_TEXT_SCROLLSPEED)) - value.xDir * radius
local yPos = value.startY + radius * sin(90 * value.scrollTime / COMBAT_TEXT_SCROLLSPEED)
return xPos, yPos
end
]]></Script>
<!-- The 20-string pool (ref CombatText1..20, each an expansion of CombatTextTemplate:
CombatTextFont, hidden, TOP anchored to UIParent BOTTOM (0,384) — re-seated per frame by
CombatText_OnUpdate). -->
<Frame name="CombatText" toplevel="true" parent="UIParent" hidden="false">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString name="CombatText1" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText2" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText3" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText4" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText5" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText6" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText7" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText8" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText9" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText10" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText11" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText12" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText13" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText14" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText15" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText16" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText17" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText18" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText19" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="CombatText20" inherits="CombatTextFont" hidden="true">
<Anchors><Anchor point="TOP" relativeTo="UIParent" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>CombatText_OnLoad()</OnLoad>
<OnEvent>CombatText_OnEvent(event)</OnEvent>
<OnUpdate>CombatText_OnUpdate(arg1)</OnUpdate>
</Scripts>
</Frame>
</Ui>
@@ -1,93 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The "someone wants to summon you" dialog (decision 1747): a registry entry on the shared
StaticPopup engine (UiPanels.xml) plus its UIParent event arm, written against the
patch-chain FrameXML (mpqx extraction, decision 0246) — StaticPopup.lua l.1336-1357 (the
entry) and UIParent.lua l.126 / l.551-552 (the registration and the show arm). The string is
GlobalStrings.lua l.852, verbatim.
It belongs to no window, like the duel and binder confirms beside it: a warlock's Ritual of
Summoning, a meeting stone and a GM's `.summon` all arrive as the same SMSG_SUMMON_REQUEST
with nothing on screen.
**The event carries NO arguments** — unlike every other confirm here. The engine keeps the
request in four globals and the dialog reads them back through GetSummonConfirmSummoner(),
GetSummonConfirmAreaName() and GetSummonConfirmTimeLeft() (`crate::ui_summon`'s header
carries the byte pin). That is also why this entry's text starts BLANK: it is one of the
engine's countdown dialogs, so StaticPopup_Show writes " " and the OnUpdate tick composes
the real line every frame from those getters plus the remaining time.
**Accepting is the only packet in the flow.** There is no decline opcode in 1.12 and no
CancelSummon binding, so Cancel and ESC send nothing and the offer simply expires on the
server's own two-minute timer — which is also what the countdown running out means. That is
why this entry, like CONFIRM_BINDER, has no OnCancel.
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim) ───────────────────────────────────────────────
-- %s summoner · %s area · %d count · %s the count's unit word (StaticPopupTimeUnit).
local CONFIRM_SUMMON =
"%s wants to summon you to %s. The spell will be cancelled in %d %s." -- l.852
local ACCEPT = "Accept" -- l.11
local CANCEL = "Cancel" -- l.465
-- ── The confirm — answering to StaticPopup.lua l.1336-1357 ─────────────────────────────
-- OnShow seeds `timeleft` from the engine, which is what arms the popup engine's countdown
-- branch; from there the dialog ticks it down locally and hides itself at zero, sending
-- nothing. GetSummonConfirmTimeLeft() returns whole seconds, already truncated by the
-- binding (`0x48b660`), so this is the reference's own resolution and not a rounding of ours.
--
-- The OnUpdate is a COMBAT LOCK, not a teardown: you may not accept a summon while
-- fighting, so Button1 disables and re-enables while the dialog stays up. Every other
-- confirm in this folder polls to decide whether to *hide*; this one polls to decide
-- whether the answer is available. Cancel stays live throughout.
local function lockAcceptWhileInCombat(_, dialog)
local button = getglobal(dialog:GetName() .. "Button1")
if UnitAffectingCombat("player") then
button:Disable()
else
button:Enable()
end
end
StaticPopupDialogs["CONFIRM_SUMMON"] = {
text = CONFIRM_SUMMON,
button1 = ACCEPT,
button2 = CANCEL,
OnShow = function()
this.timeleft = GetSummonConfirmTimeLeft()
end,
OnAccept = function()
ConfirmSummon()
end,
OnUpdate = lockAcceptWhileInCombat,
timeout = 0,
-- A summon is worth interrupting a fly-by for: without this the whole question would be
-- cancelled unseen by StaticPopup_Show's cinematic gate, and the offer would expire
-- while the player watched a cutscene.
interruptCinematic = 1,
-- v1-inert in our engine (UiPanels.xml's header lists it) — transcribed because it is
-- part of the entry an addon may read.
notClosableByLogout = 1,
hideOnEscape = 1,
}
-- ── The UIParent event arm (UIParent.lua l.551-552) ────────────────────────────────────
-- No arguments: the getters above are the payload.
function BenillaConfirmSummonDriver_OnEvent()
if event == "CONFIRM_SUMMON" then
StaticPopup_Show("CONFIRM_SUMMON")
end
end
]]></Script>
<Frame name="BenillaConfirmSummonDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("CONFIRM_SUMMON")
</OnLoad>
<OnEvent>BenillaConfirmSummonDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
@@ -45,12 +45,6 @@
on — hovering an ordinary bag slot — runs the real client's code, unedited.
── Named, not hidden: what still does not work through the sourced files ────────────────────
· `MerchantItemButton_OnClick`. The reference's `ContainerFrameItemButton_OnClick` closes its
shift-right-click-at-a-vendor arm with a `SplitStack` closure that calls that global, and
benilla's merchant window publishes `BenillaMerchantItem_OnClick` instead. Confirming a stack
split with a vendor open therefore raises. That is the same "a Benilla name shadows the
reference's" class this whole seam exists to close, one window over, and it is left loud
rather than guarded (1203): the fix is to publish the merchant global, not to hide the call.
· `REPAIR_COST` is read by the reference's repair-mode arm. It is a `GlobalStrings.lua` key and
this client sources that file too, so it resolves at runtime — and the arm is unreachable
today regardless, since `GameTooltip:SetBagItem`'s repairCost is an INTERIM 0.
@@ -96,299 +90,74 @@
refItemButtonOnEnter(button)
end
-- ══ Re-publishing the three bag verbs 0561 shadows, and why it is HERE ═════════════
-- ══ The three bag verbs benilla answers differently (decisions 0561 + 1751) ═════════
--
-- `OpenBackpack`, `CloseBackpack` and `CloseAllBags` are all defined by the sourced
-- `ContainerFrame.lua` (l.187-217, l.702-707) — and benilla answers all three differently,
-- for one reason: **0561, the director's call that a window opening your bags opens EVERY
-- equipped bag**, not just the backpack. The vendor, bank, mail and trade windows call
-- `OpenBackpack` from their OnShow so your bags come up with them and you can sell or
-- deposit from one.
-- `CloseAllBags`, `OpenBackpack` and `CloseBackpack` all exist in the sourced
-- `ContainerFrame.lua` too, and benilla's differ for one reason, not three: **0561, the
-- director's call that a window which opens your bags opens EVERY equipped bag**, not just
-- the backpack. The vendor/bank/mail/trade windows call `OpenBackpack` from their OnShow so
-- your bags come up with them and you can sell or deposit from one; the reference's own
-- body touches bag 0 alone. `CloseBackpack` is its inverse and has to match it, and
-- `CloseAllBags` is the same family's unconditional form.
--
-- The bodies live in `UiPanels.xml`, because every window that calls them is loaded by
-- harnesses that have no bag stack at all, and a nil `OpenBackpack` in a merchant test is
-- a nil for no reason. But UiPanels is manifest entry 7 and the reference's container file
-- is entry 46, so **its publications lose the collision** — the reference's bodies land on
-- top and the vendor silently stops opening your bags. That is not hypothetical: it is
-- what shipped for the length of one session before a test caught it.
-- **The `Benilla*` names, and the publish under them, are load ORDER** (1751). Order
-- decides every collision and the manifest is the order: this file loads after the
-- reference's `ContainerFrame.xml`, so the three publishes at the foot are the ones that
-- stand. The bodies lived in our UIParent.lua counterpart until 1988 retired it — a
-- harness with no bag stack now meets the loader's stand-ins for the three names
-- (`test_ui::UIPARENT_STAND_INS`), not a nil.
--
-- So the bodies stay where their callers can reach them, under `Benilla*` names that
-- nothing overwrites, and this file — entry 49, after the reference's — puts them back on
-- the reference's names. Three lines, and they are the whole of the fix.
-- What CHANGED with the swap is only the plumbing: these used to walk five benilla windows
-- by name and stash `wasOpen` on each frame. The windows are the reference's recycled
-- `ContainerFrame1..12` now — frame N is a different bag every time it opens — so the
-- was-open memory is keyed by BAG ID, and every open and close goes through the
-- reference's own `OpenBag`/`CloseBag`/`IsBagOpen` rather than a raw Show/Hide. That buys
-- the reference's own guards for free (OpenBag's CanOpenPanels + NotWhileDeadError,
-- ToggleBag's `size > 0`) and means an addon replacing those verbs is honoured here too.
BENILLA_BAG_WAS_OPEN = {}
-- The UNCONDITIONAL close, called by ShowUIPanel's native-center branch above: a centre
-- frame takes the whole screen, so nothing is being "restored afterwards" the way a vendor
-- window restores the bags it opened, and the was-open memory is deliberately ignored.
-- Range 0..NUM_CONTAINER_FRAMES rather than the old five names — the reference's own
-- CloseAllBags covers the bank bags too (l.702-707), and now so does this.
function BenillaCloseAllBags()
for bagId = 0, (NUM_CONTAINER_FRAMES or -1) do
CloseBag(bagId)
end
end
-- OpenBackpack — 0561's all-bags form. A bag already open is REMEMBERED and left alone, so
-- the paired CloseBackpack does not close a window the player opened themselves; that
-- memory is the reference's own `backpackWasOpen`, one entry per bag instead of one flag.
function BenillaOpenBackpack()
for bagId = 0, (NUM_BAG_FRAMES or -1) do
-- The backpack always exists; an equipped-bag window only when a bag is in the
-- slot — `OpenBag` makes the same test itself, but the memory has to agree with it.
if bagId == 0 or GetContainerNumSlots(bagId) > 0 then
if IsBagOpen(bagId) then
BENILLA_BAG_WAS_OPEN[bagId] = 1
else
BENILLA_BAG_WAS_OPEN[bagId] = nil
OpenBag(bagId)
end
end
end
end
function BenillaCloseBackpack()
for bagId = 0, (NUM_BAG_FRAMES or -1) do
if not BENILLA_BAG_WAS_OPEN[bagId] then
CloseBag(bagId)
end
end
end
-- The publish — after the reference's container file, so these are the definitions that
-- stand (the stock `ShowUIPanel` calls `CloseAllBags` and the vendor's OnShow
-- `OpenBackpack`, and they reach 0561's bodies).
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
-- ══ THE ITEM-PUSH DROP CARD, over the reference's own bag bar (decision 0887) ═════════
--
-- The bar is `Interface\FrameXML\MainMenuBarBagButtons.xml` now (1751 window 3), and its
-- `ItemAnimTemplate` is a `<Model>` playing `Interface\ItemAnimations\ForcedBackpackItem.mdx`.
-- **This engine's `<Model>` holds the scene the Lua API reads and writes but draws no
-- pixels** (`script/modelframe.rs` — the pane is the app renderer's job and no renderer
-- claims a FrameXML model), so those six frames load, register, and animate nothing.
--
-- That is a genuine engine difference, which is the one thing 1751 §2 keeps an adapter
-- for. So the reference's file is sourced unedited — its Models exist and are harmless —
-- and the card the player actually sees is built HERE, as a sibling Frame + Texture on the
-- same six buttons, driven by the model's OWN keyframes read out of the shipped .m2. The
-- bodies below moved verbatim from the deleted `BagFrame.xml`; every number in them is
-- byte-derived and the comments carry their provenance.
--
-- ONE THING CHANGED IN THE MOVE, and it is the reason the swap needed thought at all.
-- `ItemAnim_OnEvent` matches on `this:GetParent():GetID()`, which for a bag button is its
-- INVENTORY-slot id (20..23, via `GetInventorySlotInfo`). benilla used to translate
-- `ITEM_PUSH`'s `arg1` into its own container vocabulary (1..4) because the old bar was
-- keyed that way, and against the reference's body that comparison is false for every bag
-- — four cards that would simply never have played, with nothing raising. The fix was not
-- to adapt the reference: `ui_loot.rs`'s `push_container` stopped translating and now emits
-- `0x491bb5`'s selector verbatim, which is what addons expect too. So the id a card matches
-- on is `button:GetID()`, exactly as the reference's own body reads it.
-- ── The item-push drop animation (decision 0887) ─────────────────────────────────────────
-- An item lands in a bag: its icon pops in above the bag bar, hangs there, then falls into
-- the button that took it and shrinks away. ref ItemAnim (MainMenuBarBagButtons.xml l.4-16 +
-- .lua l.68-80, .lua l.92-94): each bag button owns a hidden child that, on ITEM_PUSH
-- naming its own container, swaps in the pushed item's icon, rewinds to sequence time 0 and
-- shows itself; OnAnimFinished hides it again. A second push on the same button RESTARTS it
-- (SetSequenceTime(0,0)); pushes into different bags run independently. That is exactly the
-- machine below, with the reference's ITEM_PUSH `arg1` translated from its inventory-slot
-- vocabulary (20..23) into ours (bagId 1..4) app-side — ui_loot.rs `push_container` carries
-- the byte-verified selector.
--
-- The reference's is a <Model> playing Interface\ItemAnimations\ForcedBackpackItem.mdx — a
-- single 4-vertex quad on one animated bone, i.e. a 2D icon card with keyframed scale, alpha
-- and offset. benilla has no Model widget (decision 0137's Cooldown took the same posture:
-- model the mechanism, let our own renderer draw it), so it is a Texture driven by the
-- model's OWN tracks, read out of the shipped .m2 with `benilla-extract m2anim`/`m2bones`:
--
-- sequence 0, CLAMP (plays once), duration 1.000 s
-- texture-weight (alpha) 0.000→0.0 0.133→1.0 0.267→1.0 1.000→0.0 linear
-- bone 0 scale 0.000→1.0 0.133→1.2 0.267→1.0 1.000→0.014 linear
-- bone 0 translation 0.000→(0,0) 0.500→(0,0) 1.000→(+0.010,-0.040)
-- the quad itself: 0.03 x 0.03 model units, blend Alpha, unlit, no depth write
--
-- So: fade in over the first 133 ms while swelling to 1.2x; settle back to full size by
-- 267 ms; from there dwindle and fade continuously for the remaining 733 ms — while the
-- POSITION holds until the half-second and only then falls. The three tracks deliberately
-- do not share a schedule: the card is already down to ~0.69 size and ~0.68 alpha when it
-- starts moving, so the drop reads as the icon being drawn into the bag rather than as a
-- solid tile sliding. Those numbers are the file's, not ours.
--
-- THE MODEL-UNIT → PIXEL SCALE is no longer ours to calibrate: the <Model> widget's
-- projection is RE-recorded now (wow-re `system/ui/scratch/modelframe-animation-clock.md`,
-- dispatched over the pet bar's B228 and folded back with it). The ortho leg gives
--
-- px per model unit = (5/3) * modelScale * layoutScale * screenHeightPx
--
-- which in FrameXML units is **1280 * modelScale * layoutScale**, aspect- and
-- resolution-independent, with the model ORIGIN at the frame rect's bottom-left. The ref's
-- `ItemAnimTemplate` (MainMenuBarBagButtons.xml:4) carries **no `scale` attribute**, so
-- modelScale is 1 and the constant is a flat 1280. Decision 0887's named gap is closed.
--
-- This REPLACES a back-fit, and the back-fit is worth naming because it looked like
-- evidence. The old 1200 came from assuming CooldownFrameTemplate's 0.04-unit sweep
-- *exactly* covers a 36 px action button at its scale="0.75" (⇒ K = 1200). The assumption
-- is the error: at the real projection that sweep is 0.04 · 1280 · 0.75 = **38.4 px**, so
-- it overhangs its button by 6.7% rather than fitting it — exactly as the autocast shine's
-- 30.72 px square overhangs its 30 px button. Solving against a wrong premise produced a
-- number that then "cross-checked" against a second model derived the same wrong way, and
-- both files cited the agreement as the reason to trust it.
--
-- Everything below is the model's own ratios times this constant, so the correction lands
-- in one place and the animation stays in proportion.
BENILLA_ITEM_PUSH_PX_PER_UNIT = 1280
BENILLA_ITEM_PUSH_DURATION = 1.0
-- ── The widget rect, and why the card is not placed against the button ─────────────────
-- A `<Model>` with no `<Size>` and no `setAllPoints` takes its rect from the **M2 header
-- bounding box** (`MD20+0xB4`, max−min per axis), consumed RAW as layout units — unlike
-- CSimpleTexture's texel fallback, there is no AbsDimension conversion. So the ref's
-- ItemAnim widget is **42.41 x 124.72**, nothing like the 36-square everyone assumes from
-- looking at the XML, and **model-space (0,0,0) lands on that rect's BOTTOM-LEFT corner**.
-- (wow-re `system/ui/scratch/modelframe-implicit-size-law.md`, §5-cross-checked: vtable
-- 0x81c5d8 +0x1c/+0x20 -> 0x76d080/0x76d0d0, bbox source 0x713640 at `713661 add esi,0xb4`.
-- The one worker who read `MD20+0xD0` — the VertexBox — got an all-zero box and concluded
-- the widget was degenerate; that wrong premise is exactly what the pair round exists to
-- catch, and it is why this looked unresolvable in 0887.)
--
-- That rect is the whole reason this animation reads the way it does: it stands 124.72 tall
-- off the button's own bottom edge, so the card's travel band reaches **54 px above the
-- button's top**. Decision 0887 could not know that and placed the card against the
-- button's CENTER instead, which is what put it in the wrong place in x and gave it no
-- horizontal drift at all.
BENILLA_ITEM_PUSH_FRAME_W = 42.41
BENILLA_ITEM_PUSH_FRAME_H = 124.72
-- Where that rect hangs off the button: the ref anchors the widget's BOTTOMRIGHT to the
-- button's, 10 in from the right for the five bag buttons and flush (0) for the keyring —
-- MainMenuBarBagButtons.xml l.19-27 (BagSlotButtonTemplate) and l.152-160 (KeyRingButton).
-- Per-button, so it is an OnLoad argument, not a constant.
BENILLA_ITEM_PUSH_ANCHOR_X = -10
-- ── The model's own numbers, unrounded ────────────────────────────────────────────────
-- Every one of these was rounded in 0887 and every rounding moved the card. The quad span
-- read as 0.03 (it is 0.0288 — a 4% size error), and the translation as (0.010, -0.040)
-- (which alone accounts for half a pixel of the landing point).
--
-- The bone transform is `v' = pivot + T(t) + S(t) * (v - pivot)` — three wow-re workers
-- derived that independently in three matrix conventions and agreed. The pivot
-- (M2CompBone+0x60) is NOT the quad's centre: it sits 0.23 px right and 0.07 px above it,
-- so the card shrinks *almost* in place and drifts a fifth of a pixel doing it. Keeping
-- the pivot as its own term rather than folding it into the centre is what makes this
-- reproduce the reference exactly at all three sampled times instead of nearly.
-- Bone 0's ROTATION track is empty (nKeys = 0), so there is no roll to model.
BENILLA_ITEM_PUSH_PIVOT_X = 0.017580900341272354
BENILLA_ITEM_PUSH_PIVOT_Y = 0.053958501666784286
BENILLA_ITEM_PUSH_QUAD_X = 0.017400000942870975
BENILLA_ITEM_PUSH_QUAD_Y = 0.053900001570582390
BENILLA_ITEM_PUSH_QUAD_SPAN = 0.028800001833587885
-- {time, value} keyframes, linear between (M2 track interpolation 1) — the file's own keys,
-- at full precision. The three tracks keep DIFFERENT schedules and that is the whole feel:
-- the card fades up while swelling to 1.2x over 133 ms, settles back to full size at
-- 267 ms, then dwindles and fades *continuously* for the remaining 733 ms — while its
-- position holds until the half second and only then falls. So it is already at ~0.69 size
-- and ~0.68 alpha when it starts to move, and it is invisible by the time it lands.
BENILLA_ITEM_PUSH_ALPHA = { {0.000, 0.0}, {0.133, 1.0}, {0.267, 1.0}, {1.000, 0.0} }
BENILLA_ITEM_PUSH_SCALE = { {0.000, 1.0}, {0.133, 1.2000000476837158}, {0.267, 1.0}, {1.000, 0.01441549975425005} }
-- Translation is per-AXIS: the card falls 51.4 px and drifts 13.3 px RIGHT, so one shared
-- 0..1 "drop" curve (0887's shape) cannot express it — the old code had no horizontal term
-- at all and moved the card the wrong way in x when it inferred one.
BENILLA_ITEM_PUSH_TRANS_X = { {0.000, 0.0}, {0.500, 0.0}, {1.000, 0.010394600220024586} }
BENILLA_ITEM_PUSH_TRANS_Y = { {0.000, 0.0}, {0.500, 0.0}, {1.000, -0.040167398750782013} }
-- Sample a {time, value} keyframe list at `t`, linear between keys, clamped at both ends
-- (the M2 sampler's own shape for a CLAMP sequence).
function BenillaItemPushAnim_Sample(track, t)
local last = table.getn(track)
if t <= track[1][1] then return track[1][2] end
for i = 2, last do
local key = track[i]
if t <= key[1] then
local prev = track[i - 1]
local span = key[1] - prev[1]
if span <= 0 then return key[2] end
return prev[2] + (key[2] - prev[2]) * (t - prev[1]) / span
end
end
return track[last][2]
end
-- Place/size/fade the card for the current clock, from the model's own transform.
--
-- The card's centre in MODEL space is `pivot + T(t) + S(t)*(quadCentre - pivot)`; times the
-- projection constant that is its offset in layout units from the model origin, which is
-- the widget rect's bottom-left corner. The rect's own BOTTOMRIGHT hangs off the button's,
-- so the card is placed against the BUTTON'S BOTTOMRIGHT — never its centre — and the
-- frame width is what converts between the two.
--
-- On screen: a button-sized card appears with its bottom edge ~13.6 px ABOVE the button's
-- top, pops to 1.2x and back while fading in, hangs there through the first half second,
-- then falls down-and-right INTO the button, shrinking to a half-pixel point at its centre
-- as it fades out. Reproduces the reference at t = 0, 0.133 and 1.0 to the hundredth of a
-- pixel (`modelframe-implicit-size-law.md` §3's table).
--
-- NOT modelled, deliberately, and both are named in the record: the reference's widget
-- CLIPS the card to its own rect, which shaves 1.94 px off the right edge at the 1.2x peak
-- **at 4:3 only** (16:10 and 16:9 clear it); and the widget clears the depth buffer inside
-- that 124.72-tall rect every paint. benilla draws this as a Texture, not a Model, so it
-- inherits neither — reproducing an aspect-conditional sliver would be hard-coding a
-- rendering artefact rather than modelling the mechanism that causes it.
function BenillaItemPushAnim_Paint(anim)
local t = anim.elapsed
local k = BENILLA_ITEM_PUSH_PX_PER_UNIT
local scale = BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_SCALE, t)
local size = BENILLA_ITEM_PUSH_QUAD_SPAN * scale * k
local cx = (BENILLA_ITEM_PUSH_PIVOT_X
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_X, t)
+ scale * (BENILLA_ITEM_PUSH_QUAD_X - BENILLA_ITEM_PUSH_PIVOT_X)) * k
local cy = (BENILLA_ITEM_PUSH_PIVOT_Y
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_Y, t)
+ scale * (BENILLA_ITEM_PUSH_QUAD_Y - BENILLA_ITEM_PUSH_PIVOT_Y)) * k
anim:SetWidth(size)
anim:SetHeight(size)
anim:SetPoint("CENTER", anim:GetParent(), "BOTTOMRIGHT",
anim.anchorX - BENILLA_ITEM_PUSH_FRAME_W + cx, cy)
anim:SetAlpha(BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_ALPHA, t))
end
-- ref ItemAnim_OnLoad (l.68-70): the card listens for ITEM_PUSH and nothing else. It is
-- authored hidden, so it costs no OnUpdate until a push shows it (events reach hidden
-- frames; OnUpdate does not — the reference's own split).
function BenillaItemPushAnim_OnLoad(anim, containerId, anchorX)
anim.containerId = containerId
-- The widget's own offset from the button's BOTTOMRIGHT: -10 for the five bag buttons,
-- flush 0 for the keyring (the ref's two different anchors — see the constant block).
anim.anchorX = anchorX or BENILLA_ITEM_PUSH_ANCHOR_X
anim.icon = anim.icon or getglobal(anim:GetName() .. "Icon")
anim.elapsed = 0
anim:RegisterEvent("ITEM_PUSH")
end
-- ref ItemAnim_OnEvent (l.72-80): `if this:GetParent():GetID() == arg1`, then
-- ReplaceIconTexture(arg2) / SetSequence(0) / SetSequenceTime(0,0) / Show. A push into a bag
-- with no button (a bank bag) matches nothing and animates nowhere, there and here.
function BenillaItemPushAnim_OnEvent(anim)
if arg1 ~= anim.containerId then return end
anim.icon:SetTexture(arg2)
anim.elapsed = 0
-- Seat the card above its button rather than at load: the bag bar's own level is set in
-- the BUTTON's OnLoad (BenillaActionBarArt_SeatAbove), which the loader may run after
-- this child exists. Re-deriving it per push is one call and can never read it stale.
anim:SetFrameLevel(anim:GetParent():GetFrameLevel() + 1)
BenillaItemPushAnim_Paint(anim)
anim:Show()
end
-- The CLAMP sequence's end is the ref's OnAnimFinished → this:Hide() (l.92-94).
function BenillaItemPushAnim_OnUpdate(anim, elapsed)
anim.elapsed = anim.elapsed + (elapsed or 0)
if anim.elapsed >= BENILLA_ITEM_PUSH_DURATION then
anim:Hide()
return
end
BenillaItemPushAnim_Paint(anim)
end
-- The six buttons and the offset each one's card hangs at, read off the reference's own
-- anchors: BOTTOMRIGHT -10 for the five bag buttons (`BagSlotButtonTemplate` l.19-27 and
-- `MainMenuBarBackpackButton` l.69-77), flush 0 for the keyring (l.152-160).
BENILLA_ITEM_PUSH_BUTTONS = {
{ "MainMenuBarBackpackButton", -10 },
{ "CharacterBag0Slot", -10 },
{ "CharacterBag1Slot", -10 },
{ "CharacterBag2Slot", -10 },
{ "CharacterBag3Slot", -10 },
{ "KeyRingButton", 0 },
}
-- Build one button's card. Authored hidden, so it costs no OnUpdate until a push shows it
-- — the reference's own split (events reach hidden frames, OnUpdate does not).
--
-- Named `$parentBenillaItemPush` rather than `$parentItemAnim`: the reference's Model
-- already owns that name on every one of these buttons, and an addon that reaches for it
-- must find the reference's frame, not ours.
function BenillaItemPushAnim_Attach(button, anchorX)
local anim = CreateFrame("Frame", button:GetName() .. "BenillaItemPush", button)
anim:Hide()
anim:SetWidth(36)
anim:SetHeight(36)
anim:SetPoint("CENTER", button, "CENTER", 0, 0)
anim.icon = anim:CreateTexture(anim:GetName() .. "Icon", "OVERLAY")
anim.icon:SetAllPoints(anim)
BenillaItemPushAnim_OnLoad(anim, button:GetID(), anchorX)
anim:SetScript("OnEvent", function() BenillaItemPushAnim_OnEvent(this) end)
anim:SetScript("OnUpdate", function() BenillaItemPushAnim_OnUpdate(this, arg1) end)
return anim
end
-- At load, because the bar is entry 48 and this file is entry 50 — every button exists and
-- has run its own OnLoad, so `GetID()` is already the reference's. A button that is absent
-- (a harness that loaded no bar) is skipped rather than raising: the card is a flourish,
-- and a missing bar is not this file's error to report.
for _, entry in ipairs(BENILLA_ITEM_PUSH_BUTTONS) do
local button = getglobal(entry[1])
if button then
BenillaItemPushAnim_Attach(button, entry[2])
end
end
]]></Script>
</Ui>
-67
View File
@@ -1,67 +0,0 @@
<Ui>
<!--
The shared cooldown-frame machine — the ref's own file split (Cooldown.lua/Cooldown.xml,
loaded before every consumer): CooldownFrame_SetTimer drives BOTH the action buttons
(ActionBar.xml) and the bag slots (BagFrame.xml), exactly as the reference shares it.
The ref's OnUpdateModel scrub/finish-flash machine lives inside our native <Cooldown>
widget instead (decision 0263), armed by SetCooldown.
`CooldownFrameTemplate` below is the reference's own declaration, and it is here because
it was MISSING: three corpus addons name it in an XML `inherits=` (Bagnon_Core,
Bongos_ActionBar, BuffCheck2), and Bagnon hangs one off EVERY item button, so a single
session logged roughly sixty
WARN ui_script: unknown template 'CooldownFrameTemplate' referenced by inherits
lines while each of those children came out as a bare, attribute-less frame.
-->
<Script><![CDATA[
-- Ref Cooldown.lua's CooldownFrame_SetTimer, re-expressed: show-and-arm only when all three
-- are positive, else hide.
--
-- THE ONE FORK, and why it is two faithful branches rather than a guard. The reference's
-- body arms a Model (`this.start = start … this:SetSequence(0)`); ours arms our native
-- <Cooldown> widget, which is where 0263 put that machine.
--
-- **Since decision 1751 the fork is almost never taken, and that is the fix.** The loader
-- now materializes a `<Model>` whose file IS the cooldown indicator as a real `<Cooldown>`
-- (`loader::frame_kind_of`) — the file is the thing's identity, and this engine draws that
-- sweep natively. So `<Model … inherits="CooldownFrameTemplate"/>`, which is what every
-- real caller writes (the reference's own ContainerFrameItemButtonTemplate, its action
-- buttons, three corpus addons), arrives here as a Cooldown and sweeps.
--
-- The `if this.SetCooldown` guard stays for the case that remains: a Model built with
-- some OTHER file, or a plain frame handed in by an addon. Calling SetCooldown
-- unconditionally is what shipped, and it raised on exactly that: a Bagnon slot holding an
-- item with a live cooldown died on `attempt to call method 'SetCooldown' (a nil value)`
-- — and, because !OmniCC wraps this very function, it took OmniCC's countdown down with
-- it. That branch shows and hides the frame and nothing more, which is a missing visual,
-- not a broken window.
function CooldownFrame_SetTimer(this, start, duration, enable)
if ( start > 0 and duration > 0 and enable > 0 ) then
if ( this.SetCooldown ) then
this:SetCooldown(start, duration)
end
this:Show()
else
this:Hide()
end
end
]]></Script>
<!--
ref Cooldown.xml l.4-14. The reference's own attributes are kept verbatim (its .mdx file,
its 0.75 scale, setAllPoints, hidden) — and the `file=` is no longer decoration: since
decision 1751 it is what the loader reads to know that an inheriting `<Model>` is really
this engine's `<Cooldown>` widget (`loader::frame_kind_of`). The template's other job is
unchanged: to exist under the reference's name so an addon's `inherits=` resolves and its
cooldown child inherits the geometry the addon is counting on.
Its two `<Scripts>` — OnUpdateModel / OnAnimFinished — are deliberately NOT transcribed.
Both are real 1.12 script slots that this engine does not fire (see
`benilla-ui/src/script/object/events_regions.rs`, which raises on any name nothing fires,
and measured both at zero corpus call sites), so declaring them would trade sixty
`unknown template` warnings for sixty `SetScript unsupported` ones and change nothing else.
The scrub they drive is our <Cooldown> widget's, not this frame's.
-->
<Model name="CooldownFrameTemplate" file="Interface\Cooldown\UI-Cooldown-Indicator.mdx"
scale="0.75" setAllPoints="true" hidden="true" virtual="true"/>
</Ui>
-965
View File
@@ -1,965 +0,0 @@
<Ui>
<!--
benilla's Enchanting window (decision 0437 phase 3): the 1.12 `CraftFrame`, re-expressed from
the reference `Blizzard_CraftUI.xml`/`.lua` (session scratchpad `ref-ui/`, decision 0246
discipline — never committed) onto our own engine/feeds. Geometry/art-paths/structure below are
QUOTED from the ref (line citations inline); the Lua is the ref's logic turned onto benilla's
Era API (`GetCraftName`/`GetCraftDisplaySkillLine`/`GetNumCrafts`/`GetCraftInfo`/reagents/
`GetCraftSpellFocus`/`DoCraft`/`CloseCraft`, decision 0437's engine surface,
`crates/benilla-ui/src/script/craft.rs`). The exact twin of `TradeSkillFrame.xml` — same
engine-gap substitutions, same FLAT-list INTERIM, same doc conventions — this header only calls
out where CRAFT'S OWN ref actually differs (verified against `Blizzard_CraftUI.lua`, not
assumed from TradeSkill's shape).
NOT an NPC session (unlike Gossip/Merchant/Trainer): the window opens client-side off your own
cast of an effect-47 opener spell — no NPC, no server round trip to open it (0437 §"What
scoping verified", TradeSkillFrame.xml's own identical header note). It registers
`CRAFT_SHOW`/`CRAFT_UPDATE`/`CRAFT_CLOSE` on ITSELF (TradeSkillFrame.xml's own precedent: this
engine has no separate FrameXML driver that dispatches SHOW for you).
Modeled on the two known-good dressed windows for the framing (MerchantFrame.xml's own header
comment, TradeSkillFrame.xml's own precedent for this exact art family — both windows are
`ClassTrainerFrame`-styled 384×512 four-quadrant windows and share BYTE-IDENTICAL geometry for
every region this file also carries: the portrait, the four border quadrants' Size/Anchors, the
skill-border/horizontal-bar decorative art, the rank StatusBar block, the persistent selection
highlight, the 8-row list + its ScrollFrame, the detail header art, the reagent grid's anchor
chain (down to the SAME "Reagent7 anchors off Reagent6, not Reagent5" ref quirk), and the
Create/Exit button positions):
- MerchantFrame.xml/TradeSkillFrame.xml — the 384×512 four-quadrant window art (no
<Backdrop>: the art IS the frame), the "player" portrait binding (`SetPortraitTexture(region,
"player")` — ref l.89-91 confirms Craft ALSO binds "player", not "npc": this is not an NPC
session), the UIPanel slot system (carries NO hardcoded <Anchors> — ShowUIPanel/SetLeftFrame
SetPoints it), OnShow/OnHide PlaySound pair.
- TradeSkillFrame.xml — the FLAT layout constraint (below), the faux-scroll list kit
(FauxScrollFrameTemplate, ScrollTemplates.xml), the locally-reconstructed
UIPanelButtonTemplate/close-button/item-button idioms (no such templates ship in this house
dialect — TalentFrame.xml's own ENGINE API note, restated per-window, StackSplit.xml's own
"own local virtual template, not a cross-file inherits" convention), the persistent
selection-highlight Frame, and the `button.craftIndex`/`Button:SetTextColor` engine-gap
substitutions (deviation 7 below).
FLAT layout — a CHOICE inherited from TradeSkillFrame.xml, which inherited it from
TrainerFrame.xml, which stated it as a house constraint it never was. A runtime-shown child
<Frame> DOES render its own <Layers> FontStrings in this engine (probed; decision 1517). The
detail-pane FontStrings + the title live on BenillaCraftFrame's OWN <Layers> (OVERLAY, above
the window's own BACKGROUND/BORDER/ARTWORK art groups) because that is how this window was
built, not because nesting fails. The ref's
`CraftDetailScrollFrame`/`CraftDetailScrollChildFrame` therefore don't exist here — the detail
is a FIXED (non-scrolling) pane, computed once from the flattened ScrollFrame+ScrollChild
offsets (TOPLEFT 20,-234 + each child's own ref-quoted offset — BYTE-IDENTICAL to TradeSkill's
own detail-scroll anchor, since both refs place it there). Per-region regions that DO need
Show()/Hide() live on Button/StatusBar/Texture children (those render their own Layers) — the
rank bar, the detail icon, and the 8 reagent slots.
v1 DEVIATIONS (pinned to decision 0437's in-flight wow-re §5 dispatch where noted):
1. FLAT LIST, NO HEADERS. `GetCraftInfo`'s `craftType` is never `"header"` in v1 (the engine
API contract) — the ref's header BRANCH (the row's fold icon/texture swap, the row-text
21px-right reposition loop) is still dropped outright, not even as a no-op:
`Expand`/`CollapseCraftSkillLine` exist but are wired as true engine-side NO-OPS
(`craft.rs`'s own module doc + `Expand/CollapseCraftSkillLine` comment) — the 1.12 craft
list is single-group, so the real client's own header path is unexercised here; a click
changes nothing observable beyond the button's own local state.
`CraftExpandButtonFrame`/`CraftCollapseAllButton` (ref l.265-325) now SHIP, for fidelity —
this deviation's own earlier revision omitted them entirely; closed this session
(TradeSkillFrame.xml's own identical CollapseAll tab, restored there the same session).
`BenillaCraftFrame_Update`'s own restored tail (ref l.248-282, minus the row-text-reposition
loop, still dropped above) counts headers to size the tab's texture — since `GetCraftInfo`
never returns `"header"`, that count is always 0, so the tail's own `numHeaders > 0` branch
(ref l.270) always takes the ELSE arm: `BenillaCraftExpandButtonFrame` ends up HIDDEN in
practice, a faithful-but-currently-inert outcome (this file's own deviation 2 "ships for
fidelity, never actually visible yet" shape). `CraftTypeColor["header"]`/`["used"]`/
`["none"]` are kept in the transcribed color table for fidelity but are unreachable this way
(TradeSkillFrame.xml's own identical posture). v1 still ships no filter dropdowns.
2. THE SPELL-FOCUS TOOL NEVER REDDENS. `BuildColoredListString` is REUSED, not redefined — it
is already a true Lua GLOBAL after `TradeSkillFrame.xml` loads (`if not
BuildColoredListString then function BuildColoredListString(...) ... end end`, no `local`),
and `TradeSkillFrame.xml`'s own header comment names this file as the second consumer.
`CraftFrame.xml` is loaded AFTER `TradeSkillFrame.xml` in `ui_script/mod.rs`'s load list, so
this file calls it directly with no guard/redefinition. It reds an unmet tool byte-faithfully,
but `GetCraftSpellFocus`'s `hasTool` has no client-side proximity check yet (0437 INTERIM,
pinned to the §5 dispatch, TradeSkillFrame.xml's own identical note) — v1 never actually
reds a tool in practice.
3. NO DRESSING-ROOM / CHAT-LINK INSERT. The ref's Ctrl+click `DressUpItemLink` / Shift+click
`ChatFrameEditBox:Insert` on `CraftIcon`/`CraftReagent*` (ref l.44-51, 555-563) are dropped
— the cursor-item arc hasn't landed (MerchantFrame.xml's/TradeSkillFrame.xml's own identical
"a no-op here until the cursor-item arc" precedent). `CraftIcon`'s `this.hasItem = 1`
OnLoad flag (ref l.553) is dropped too — it is read only by `UIParent.lua`'s own generic
Ctrl-click dressing-room handler (grepped), which this dropped branch never reaches; a real,
now-inert vestige, not a benilla omission.
4. NO `SKILL_LINES_CHANGED` self-listen on the rank StatusBar (ref l.253-260, TradeSkillFrame's
own identical deviation): the skill-up feedback/diff-watcher is 0437 phase 4, not yet built
— the bar's value/color/text refresh only rides this window's own SHOW/UPDATE/selection
paint.
5. NO `PLAYER_ENTERING_WORLD` hide (TradeSkillFrame.xml's own identical choice for this session
shape) — opening this window is your own client-side cast, not an NPC session.
6. NO BEAST TRAINING / NO TRAINING POINTS (0437's own scope line — the pet arc owns Beast
Training's CraftFrame use). Every ref region/branch that exists ONLY to serve a pet ability
row is omitted, not stubbed: `CraftFramePointsLabel`/`CraftFramePointsText`
(`Craft_UpdateTrainingPoints`, ref l.428-438), the per-row `Craft<n>Cost` FontString (always
blank in v1 since `GetCraftInfo`'s `trainingPointCost` is a hardcoded `0` — `craft.rs`'s own
module doc), and the Requirements line's pet-level fallback branch (`requiredLevel`, also a
hardcoded `0` — ref l.364-369, unreachable). `UNIT_PET_TRAINING_POINTS` is not registered
(nothing left to drive it). `GetCraftButtonToken()` always returns `"CREATE"` — Beast
Training's `"TRAIN"` token is the only other value the ref ever returns, and that branch is
out of scope (`craft.rs`'s own note).
7. ENGINE API gaps (TradeSkillFrame.xml's own "no engine equivalent" posture, restated per
substitution actually used here):
- (CLOSED — the row name is the button's own label now.) An earlier revision recorded
`Button:SetTextColor`/`:LockHighlight`/`:UnlockHighlight` as having no engine method and
routed row colouring through a `$parentName` child FontString. The engine has shipped
all three for a long time; the note went stale and the workaround outlived it, costing
this window the white hover/selected label the real client has (decision 1605). The row
name is now a `<ButtonText>` (the ref's `Craft<n>Text`) under the base's
`<NormalFont>/<HighlightFont>/<DisabledFont>` trio, and Update calls `SetTextColor` /
`LockHighlight` / `UnlockHighlight` where the ref does (l.190/234/236). `$parentSubText`
stays a child FontString, as it is in the ref, and is whitened by hand for the selected
row (l.232). NOT taken: the ref's per-row `craftButton.r/g/b` stash and the base's own
`OnEnter`/Craft's `OnLeave` subtext recolour dance (l.191-193, xml l.22-26) — with
Beast Training out of scope (deviation 6) `craftSubSpellName` is always `""`, so every
one of those paints an empty string. The row still carries NO hover tooltip: the ref
has none either. (An earlier revision of this line added "TradeSkill's own row hover IS
a real ref behavior; Craft's is not" — WRONG about *tooltips*, and never checked:
`TradeSkillSkillButtonTemplate` overrides `OnClick` alone, and the shared base
`ClassTrainerSkillButtonTemplate` (`Interface\FrameXML\ClassTrainerFrameTemplates.xml`)
only recolours `$parentSubText` on hover. NEITHER window's rows tooltip.)
- `Button:GetID()`/`:SetID()` have no native method (same grep) — the ref's
`craftButton:SetID(craftIndex)` + `this:GetID()` pair becomes a plain Lua field,
`button.craftIndex`, set in the Update loop and read by the click handler
(`ClassTrainerSkillButtonTemplate`'s own `this.r/g/b`-restoring `OnLeave` — a color dance
made moot by the FontString substitution above — is dropped for the same reason
TradeSkillFrame.xml's own row `OnLeave` dropped it: nothing ever recolors the row on
hover in this engine, so there is nothing to restore).
- `EditBox:SetNumber`/`SecondsToTime` are NOT needed here at all: Craft has no repeat-count
input (deviation 8) and no per-recipe cooldown concept (no `CraftSkillCooldown` region
exists in the ref) — TradeSkillFrame.xml's own engine-gap shims for those simply don't
apply to this window.
8. NO CREATE ALL (a REAL ref difference, not an engine gap — verified: `CraftCreateButton`'s
OnClick is bare `DoCraft(GetCraftSelectionIndex())`, ref l.667, no count argument
anywhere). This window ships ONE action button; no repeat-count EditBox, no -/+ buttons, no
`CraftCreateAllButton`, no `GetCraftskillRepeatCount`/`UPDATE_CRAFTRECAST` — `craft.rs`'s
own module doc.
9. Dead ref code dropped, not transcribed (TradeSkillFrame.xml's own `reagentToAnchorTo`
precedent for exactly this move): `CraftSubTypeColor` + its `subColor` local (ref l.15-22,
189) are computed but never read anywhere in the reference — grepped, confirmed zero other
uses — a real vestige. The row-width scrollbar toggle (ref l.174-187, `293` vs `323`) is
dropped the same way TradeSkill's was: the ScrollFrame itself is always visible in both
engines (only its *scrollbar* toggles on overflow), so rows are fixed at 293
(TradeSkillFrame.xml's own row width).
10. SELECTION SURVIVES `CRAFT_SHOW` (a deliberate deviation from Craft's OWN ref, flagged):
the real `CraftFrame_Show` (ref l.26-49) unconditionally re-selects index 1 on every open,
discarding whatever was selected before. Our engine's `set_craft` already PRESERVES the
selection across a re-push by spell id (`craft.rs`'s own doc, TradeSkill's identical
`set_trade_skill` law) specifically so re-opening the same skill line doesn't visibly jump
the detail pane — forcing index 1 on every Show would fight that mechanism for no benefit.
`BenillaCraftFrame_Show` therefore mirrors `BenillaTradeSkillFrame_Show`'s own pattern
(keep a nonzero selection, else pick 1) instead of Craft's own ref line-for-line.
11. THE ITEM-TARGET PICK UX NOTE (0437 §"What scoping verified", `craft.rs`'s own
`needs_item_target` doc): `DoCraft` on an `ENCHANT_ITEM`-family recipe arms an APP-SIDE
item-target pick (the `TARGET_FLAG_ITEM` cast-target flow) — this XML plays NO part in
that flow; `CraftCreateButton_OnClick` below is the same bare `DoCraft(...)` call
regardless of which recipe is selected, exactly like the ref's own button.
12. GlobalStrings the ref reads (`REQUIRES_LABEL`, `SPELL_REAGENTS`, `PARENS_TEMPLATE`,
`CREATE`, `EXIT`) are declared LOCALLY below as the byte-exact 1.12 enUS values (session
scratchpad extraction, decision 0246's discipline, line-cited per constant),
TradeSkillFrame.xml's own idiom — with ONE exception: `CREATE` is declared as a REAL Lua
GLOBAL (`CREATE = CREATE or "Create"`, guarded so a second file loading it stays
idempotent), not `local`, because `getglobal(GetCraftButtonToken())` (ref l.106, faithfully
kept in `BenillaCraftFrame_Update` below) needs an actual `_G["CREATE"]` entry to
resolve — a genuine structural requirement of that ref call, not a style choice.
`GRAY_FONT_COLOR`/`HIGHLIGHT_FONT_COLOR` are Fonts.xml globals (CharacterFrame.xml's own
precedent for reading them unqualified).
Registers at the left UIPanel slot (`UiPanels.xml`). The ref's OWN registration
(`Blizzard_CraftUI.lua:24`, `UIPanelWindows["CraftFrame"] = { area = "left", pushable = 4 }`) is
`pushable = 4` — VERIFIED at that line (NOT `3`; `3` is `TradeSkillFrame`'s own line 13 value, a
different window/depth — grepped both files directly to confirm before using either number).
ESC-closable through the standard UIPanel chain. `CloseCraft()` fires on hide for ANY reason
(ESC, close X, Exit, `CRAFT_CLOSE`, or another left-slot window displacing it) —
TradeSkillFrame.xml's own `CloseTradeSkill`-on-hide precedent, adapted (no server round trip per
0437, purely client-side session teardown).
-->
<Script><![CDATA[
-- ── Constants (ref Blizzard_CraftUI.lua:2-4) ────────────────────────────────────────────────
local CRAFTS_DISPLAYED = 8 -- CRAFTS_DISPLAYED (ref l.2)
local MAX_CRAFT_REAGENTS = 8 -- MAX_CRAFT_REAGENTS (ref l.3)
local CRAFT_SKILL_HEIGHT = 16 -- CRAFT_SKILL_HEIGHT (ref l.4)
-- The recipe-row color table (ref l.6-13), transcribed verbatim including "header"/"used"/
-- "none" — all UNREACHABLE in v1 (this file's header comment, deviation 1). CraftSubTypeColor
-- (ref l.15-22) is DROPPED outright — dead ref code, deviation 9.
CraftTypeColor = {}
CraftTypeColor["optimal"] = { r = 1.00, g = 0.50, b = 0.25 }
CraftTypeColor["medium"] = { r = 1.00, g = 1.00, b = 0.00 }
CraftTypeColor["easy"] = { r = 0.25, g = 0.75, b = 0.25 }
CraftTypeColor["trivial"] = { r = 0.50, g = 0.50, b = 0.50 }
CraftTypeColor["used"] = { r = 0.50, g = 0.50, b = 0.50 }
CraftTypeColor["header"] = { r = 1.00, g = 0.82, b = 0 }
CraftTypeColor["none"] = { r = 0.25, g = 0.75, b = 0.25 }
-- ── GlobalStrings the ref reads (byte-exact 1.12 enUS, this file's header comment cites the
-- extraction + the CREATE-must-be-global exception) — chunk-local except CREATE. ─────────────
local REQUIRES_LABEL = "Requires:" -- REQUIRES_LABEL (GlobalStrings.lua:3339)
local PARENS_TEMPLATE = "(%s)" -- PARENS_TEMPLATE (2984)
local EXIT_LABEL = "Exit" -- EXIT (1933)
CREATE = CREATE or "Create" -- CREATE (871) -- REAL global, see header comment
-- BuildColoredListString is REUSED from TradeSkillFrame.xml (already a true global by the time
-- this file loads — this file's header comment, deviation 2). No redefinition here.
-- ── Detail pane ─────────────────────────────────────────────────────────────────────────────
-- No crafts known at all (ref l.131-150): blank the whole detail pane. Regions on this window's
-- own Layers "hide" via SetText("") — TradeSkillFrame.xml's own convention, which predates
-- region Show/Hide (region.rs has the real pair now); equivalent either way.
function BenillaCraftFrame_HideDetails()
getglobal("BenillaCraftFrameTitleText"):SetText(GetCraftName())
getglobal("BenillaCraftName"):SetText("")
getglobal("BenillaCraftRequirements"):SetText("")
getglobal("BenillaCraftDescription"):SetText("")
getglobal("BenillaCraftIcon"):Hide()
for i = 1, MAX_CRAFT_REAGENTS do
getglobal("BenillaCraftReagent" .. i):Hide()
end
getglobal("BenillaCraftCreateButton"):Disable()
end
-- Fill the detail pane for the selected recipe `id` (ref CraftFrame_SetSelection, l.285-408;
-- the header branch is dropped — deviation 1, never reached). Also the SOURCE of the selection
-- highlight's color (set once here, ref l.305-306).
function BenillaCraftFrame_SetSelection(id)
if not id or id == 0 then
BenillaCraftFrame_HideDetails()
return
end
SelectCraft(id)
if GetCraftSelectionIndex() > GetNumCrafts() then
return
end
local craftName, craftSubSpellName, craftType = GetCraftInfo(id)
local color = CraftTypeColor[craftType]
if color then
getglobal("BenillaCraftHighlight"):SetVertexColor(color.r, color.g, color.b)
end
-- General info (ref l.308-320).
getglobal("BenillaCraftFrameTitleText"):SetText(GetCraftName())
getglobal("BenillaCraftName"):SetText(craftName)
local icon = getglobal("BenillaCraftIcon")
icon:Show()
icon:SetNormalTexture(GetCraftIcon(id) or BENILLA_FALLBACK_ICON)
-- The description + the Reagents label's DYNAMIC reposition (ref l.312-318, re-expressed
-- here): when there's a description, the label sits 10px below its bottom edge; when
-- there isn't, it sits directly where the (blank) description would have been.
if GetCraftDescription(id) then
getglobal("BenillaCraftDescription"):SetText(GetCraftDescription(id))
getglobal("BenillaCraftReagentLabel"):SetPoint("TOPLEFT", "BenillaCraftDescription", "BOTTOMLEFT", 0, -10)
else
getglobal("BenillaCraftDescription"):SetText(" ")
getglobal("BenillaCraftReagentLabel"):SetPoint("TOPLEFT", "BenillaCraftDescription", "TOPLEFT", 0, 0)
end
-- Reagents (ref l.321-359): up to MAX_CRAFT_REAGENTS, gray-out + creatable gate.
local creatable = true
local numReagents = GetCraftNumReagents(id)
for i = 1, MAX_CRAFT_REAGENTS do
local reagent = getglobal("BenillaCraftReagent" .. i)
if i <= numReagents then
local reagentName, reagentTexture, need, have = GetCraftReagentInfo(id, i)
if not reagentName or not reagentTexture then
-- Item template not landed yet for this reagent — hide it, like the ref does
-- for any nil reagent (engine API note, decision 0437).
reagent:Hide()
else
reagent:Show()
reagent.icon:SetTexture(reagentTexture)
reagent.name:SetText(reagentName)
have = have or 0
if have < need then
reagent.icon:SetVertexColor(0.5, 0.5, 0.5)
reagent.name:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
creatable = false
else
reagent.icon:SetVertexColor(1.0, 1.0, 1.0)
reagent.name:SetTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
end
local haveText = have
if have >= 100 then haveText = "*" end
reagent.count:SetText(haveText .. " /" .. need)
end
else
reagent:Hide()
end
end
-- Requires: line (ref l.361-372) — ONE concatenated FontString, unlike TradeSkill's split
-- Label+Text pair (Craft's own ref genuinely differs here — this file's header comment
-- points at TradeSkillFrame.xml's split for comparison). The pet-level fallback branch
-- (ref l.364-369) is unreachable in v1 (deviation 6) and dropped.
local requiredTotems = BuildColoredListString(GetCraftSpellFocus(id))
if requiredTotems then
getglobal("BenillaCraftRequirements"):SetText(REQUIRES_LABEL .. " " .. requiredTotems)
else
getglobal("BenillaCraftRequirements"):SetText("")
end
if creatable then
getglobal("BenillaCraftCreateButton"):Enable()
else
getglobal("BenillaCraftCreateButton"):Disable()
end
end
-- ── The list page (ref CraftFrame_Update, l.99-283 — the header/expand-icon/training-points
-- tail is dropped: deviations 1/6, never reached in v1) ────────────────────────────────────────
function BenillaCraftFrame_Update()
getglobal("BenillaCraftFrameTitleText"):SetText(GetCraftName())
getglobal("BenillaCraftCreateButton"):SetText(getglobal(GetCraftButtonToken()))
local numCrafts = GetNumCrafts()
if numCrafts == 0 then
BenillaCraftFrame_HideDetails()
else
getglobal("BenillaCraftCollapseAllButton"):Enable() -- ref l.158 (no matching Disable()
-- anywhere in Craft's own ref — a real asymmetry vs. TradeSkill's own l.86/94 pair,
-- kept as the ref has it, not "fixed").
end
local scrollFrame = getglobal("BenillaCraftListScrollFrame")
FauxScrollFrame_Update(scrollFrame, numCrafts, CRAFTS_DISPLAYED, CRAFT_SKILL_HEIGHT)
local craftOffset = FauxScrollFrame_GetOffset(scrollFrame)
-- The skill-rank StatusBar (ref l.107-123): Show/Hide are real here (real widgets, not
-- flattened Layer regions).
local name, rank, maxRank = GetCraftDisplaySkillLine()
local rankFrame = getglobal("BenillaCraftRankFrame")
if name then
getglobal("BenillaCraftRankFrameSkillName"):SetText(name)
rankFrame:SetStatusBarColor(0.0, 0.0, 1.0, 0.5)
getglobal("BenillaCraftRankFrameBackground"):SetVertexColor(0.0, 0.0, 0.75, 0.5)
rankFrame:SetMinMaxValues(0, maxRank)
rankFrame:SetValue(rank)
getglobal("BenillaCraftRankFrameSkillRank"):SetText(rank .. "/" .. maxRank)
rankFrame:Show()
getglobal("BenillaCraftSkillBorderLeft"):Show()
getglobal("BenillaCraftSkillBorderRight"):Show()
else
rankFrame:Hide()
getglobal("BenillaCraftSkillBorderLeft"):Hide()
getglobal("BenillaCraftSkillBorderRight"):Hide()
end
local highlight = getglobal("BenillaCraftHighlightFrame")
highlight:Hide()
local selection = GetCraftSelectionIndex()
for i = 1, CRAFTS_DISPLAYED do
local craftIndex = i + craftOffset
local button = getglobal("BenillaCraft" .. i)
if craftIndex <= numCrafts then
local craftName, craftSubSpellName, craftType, numAvailable = GetCraftInfo(craftIndex)
button.craftIndex = craftIndex
local color = CraftTypeColor[craftType]
if color then
button:SetTextColor(color.r, color.g, color.b) -- ref l.190
button.subtext:SetTextColor(color.r, color.g, color.b) -- ref l.195
end
if (numAvailable or 0) == 0 then
button:SetText(" " .. craftName)
else
button:SetText(" " .. craftName .. " [" .. numAvailable .. "]")
end
if craftSubSpellName and craftSubSpellName ~= "" then
button.subtext:SetText(format(PARENS_TEMPLATE, craftSubSpellName))
else
button.subtext:SetText("")
end
button:Show()
-- TWO things paint a selected row, and the ref does both here (l.229-236): the
-- glow bar is BenillaCraftHighlightFrame, and the WHITE LABEL is the row's own
-- LockHighlight() — the template's `<HighlightFont inherits="GameFontHighlight">`
-- instance winning over the difficulty colour SetTextColor just wrote, which
-- reaches the NORMAL instance only. The subtext is whitened by hand because it is
-- a child FontString, not the button's label, exactly as the ref does it (l.232).
if selection == craftIndex then
highlight:SetPoint("TOPLEFT", button, "TOPLEFT", 0, 0)
highlight:Show()
button:LockHighlight()
button.subtext:SetTextColor(1.0, 1.0, 1.0)
else
button:UnlockHighlight()
end
else
button.craftIndex = nil
button:Hide()
end
end
-- Count headers + how many stay folded (ref l.249-259), to drive the CollapseAll tab's own
-- texture below — restored, deviation 1 (still functionally inert: GetCraftInfo never
-- returns "header" in v1, so numHeaders is always 0 here).
local numHeaders, notExpanded = 0, 0
for i = 1, numCrafts do
local craftName, craftSubSpellName, craftType, numAvailable, isExpanded = GetCraftInfo(i)
if craftName and craftType == "header" then
numHeaders = numHeaders + 1
if not isExpanded then
notExpanded = notExpanded + 1
end
end
end
-- If all headers are not expanded then show the collapse texture, otherwise the expand one
-- (ref l.261-267).
local collapseAll = getglobal("BenillaCraftCollapseAllButton")
if notExpanded ~= numHeaders then
collapseAll.collapsed = nil
collapseAll:SetNormalTexture("Interface\\Buttons\\UI-MinusButton-Up")
else
collapseAll.collapsed = true
collapseAll:SetNormalTexture("Interface\\Buttons\\UI-PlusButton-Up")
end
-- Show the tab only when there are headers to fold (ref l.269-282; the row-text-reposition
-- loops there stay dropped, deviation 1) — always Hidden in v1 since numHeaders is always 0.
if numHeaders > 0 then
getglobal("BenillaCraftExpandButtonFrame"):Show()
else
getglobal("BenillaCraftExpandButtonFrame"):Hide()
end
end
-- A row click selects it (ref CraftButton_OnClick + SetSelection's non-header path; the
-- header-fold branch is dropped, deviation 1). Left-button only. NO OnEnter/OnLeave here —
-- Craft's OWN ref never adds a row-hover tooltip (verified, header comment deviation 7).
function BenillaCraftSkillButton_OnClick(button, mouseButton)
if mouseButton ~= "LeftButton" then return end
local index = button.craftIndex
if not index then return end
BenillaCraftFrame_SetSelection(index)
BenillaCraftFrame_Update()
end
-- The window's wheel handler (bubbled up the parent chain from a row) → one row via the faux
-- kit (TradeSkillFrame.xml's own note: the engine bubbles a spin up the parent chain, so the
-- handler lives on the window, not a child).
function BenillaCraftFrame_OnMouseWheel(delta)
BenillaFauxScrollFrame_OnMouseWheel(getglobal("BenillaCraftListScrollFrame"), delta)
end
-- ── The CollapseAll tab (ref CraftCollapseAllButton_OnClick, l.417-425) — TradeSkillFrame.xml's
-- own identical shape: toggles Expand/CollapseCraftSkillLine(0) off the button's own `.collapsed`
-- flag (re-derived every Update() above), no direct BenillaCraftFrame_Update() call (the ref
-- doesn't call one either). UNLIKE TradeSkill's own pair, `Expand`/`CollapseCraftSkillLine` are
-- true engine no-ops here (header comment, deviation 1) — no touched flag, no CRAFT_UPDATE, so a
-- click changes nothing observably beyond this button's own local state until the next Update()
-- runs some other way. ──────────────────────────────────────────────────────────────────────
function BenillaCraftCollapseAllButton_OnClick(self)
if self.collapsed then
self.collapsed = nil
ExpandCraftSkillLine(0)
else
self.collapsed = true
FauxScrollFrame_SetOffset(getglobal("BenillaCraftListScrollFrame"), 0)
CollapseCraftSkillLine(0)
end
end
-- ── The detail icon (ref CraftIcon OnEnter, l.564-568 — the Ctrl/Shift dressing-room branches
-- dropped, deviation 3): SetCraftSpell, NOT SetCraftItem — Craft has no product-item concept
-- (craft.rs's own module doc). The binding behind it is a SELECTOR into the shared spell/item
-- builders (`SetCraftSpell 0x533e90`); a CREATE_ITEM recipe therefore renders the product's
-- full ITEM tooltip here even though the row's ICON is the spell's (Law D) — the two are
-- different laws on the same row, byte-verified. ─────────────────────────────────────────────
function BenillaCraftIcon_OnEnter(button)
if GetCraftSelectionIndex() ~= 0 then
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
GameTooltip:SetCraftSpell(GetCraftSelectionIndex())
GameTooltip:Show()
end
end
-- ── The reagent slots (ref CraftItemTemplate OnEnter, l.31-35 — the dressing-room/chat-link
-- OnClick branches dropped, deviation 3) ────────────────────────────────────────────────────
function BenillaCraftReagent_OnLoad(button, index)
button.reagentIndex = index
local n = button:GetName()
button.icon = getglobal(n .. "Icon")
button.name = getglobal(n .. "Name")
button.count = getglobal(n .. "Count")
end
function BenillaCraftReagent_OnEnter(button)
GameTooltip:SetOwner(button, "ANCHOR_TOPLEFT")
GameTooltip:SetCraftItem(GetCraftSelectionIndex(), button.reagentIndex)
GameTooltip:Show()
end
-- ── Create / Exit (ref l.654-670 — no Create All, no repeat-count input: deviation 8) ───────
function BenillaCraftCreateButton_OnClick()
-- Bare index → spell id, exactly the ref's own OnClick (ref l.667). An ENCHANT_ITEM recipe
-- arms an app-side item-target pick from here — this XML plays no part in that (header
-- comment, deviation 11).
DoCraft(GetCraftSelectionIndex())
end
function BenillaCraftCancelButton_OnClick()
HideUIPanel(getglobal("BenillaCraftFrame"))
end
-- ── The window lifecycle (MerchantFrame.xml/TradeSkillFrame.xml's own shape) ────────────────
function BenillaCraftFrame_OnLoad(frame)
frame:RegisterEvent("CRAFT_SHOW")
frame:RegisterEvent("CRAFT_UPDATE")
frame:RegisterEvent("CRAFT_CLOSE")
-- SPELLS_CHANGED (ref l.58/92-93): a real, non-Beast-Training event (a newly learned enchant
-- rank should refresh the list) — registered for fidelity; forward-compatible if this
-- engine doesn't fire it yet (TradeSkillFrame.xml's own UNIT_PORTRAIT_UPDATE posture).
-- UNIT_PET_TRAINING_POINTS (ref l.59) is NOT registered — nothing left to drive it
-- (deviation 6, Craft_UpdateTrainingPoints doesn't exist in v1).
frame:RegisterEvent("SPELLS_CHANGED")
frame:RegisterEvent("UNIT_PORTRAIT_UPDATE")
local scrollFrame = getglobal("BenillaCraftListScrollFrame")
scrollFrame.updateFunc = BenillaCraftFrame_Update
end
-- Fresh session paint (ref CraftFrame_Show, l.26-49 — the header index-select branch is
-- dropped, deviation 1; the selection-reset itself is ALSO overridden, deviation 10: this
-- keeps a persisted selection instead of always forcing index 1).
function BenillaCraftFrame_Show(frame)
getglobal("BenillaCraftCreateButton"):Disable()
if GetCraftSelectionIndex() == 0 then
if GetNumCrafts() > 0 then
BenillaCraftFrame_SetSelection(1)
end
else
BenillaCraftFrame_SetSelection(GetCraftSelectionIndex())
end
FauxScrollFrame_SetOffset(getglobal("BenillaCraftListScrollFrame"), 0)
SetPortraitTexture(getglobal("BenillaCraftFramePortrait"), "player")
BenillaCraftFrame_Update()
ShowUIPanel(frame)
end
function BenillaCraftFrame_OnEvent(frame)
if event == "CRAFT_SHOW" then
BenillaCraftFrame_Show(frame)
elseif event == "CRAFT_UPDATE" then
-- ref l.68-87: re-anchor the selection if it's fallen off a (possibly shorter) list.
local selection = GetCraftSelectionIndex()
if selection > 1 and selection <= GetNumCrafts() then
BenillaCraftFrame_SetSelection(selection)
elseif GetNumCrafts() > 0 then
BenillaCraftFrame_SetSelection(1)
FauxScrollFrame_SetOffset(getglobal("BenillaCraftListScrollFrame"), 0)
end
BenillaCraftFrame_Update()
elseif event == "CRAFT_CLOSE" then
HideUIPanel(frame)
elseif event == "SPELLS_CHANGED" then
BenillaCraftFrame_Update()
elseif event == "UNIT_PORTRAIT_UPDATE" then
if arg1 == "player" then
SetPortraitTexture(getglobal("BenillaCraftFramePortrait"), "player")
end
end
end
function BenillaCraftFrameCloseButton_OnClick()
HideUIPanel(getglobal("BenillaCraftFrame"))
end
]]></Script>
<!--
One list row (ref CraftButtonTemplate inherits ClassTrainerSkillButtonTemplate, l.4-28 for the
template, l.344-415 for the instances): a 293×16 text row, name + a parenthesized rank subtext
column (unlike TradeSkill's own row template, which carries no subname — this file's header
comment) — TradeSkillFrame.xml's own BenillaTradeSkillSkillButtonTemplate shape, plus the fixed
subtext column (TrainerFrame.xml's own Name/SubText fixed-column precedent, since this engine
has no FontString-width-based dynamic anchor). NO OnEnter/OnLeave — Craft's own ref adds no row
hover tooltip (header comment, deviation 7).
-->
<Button name="BenillaCraftSkillButtonTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentSubText" inherits="GameFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="99" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="190" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<!-- THE ROW NAME IS THE BUTTON'S OWN ButtonText (the ref's `Craft<n>Text`), under the base's
font TRIO — that is the whole white-on-hover / white-on-selected mechanism, and no script
does it: `GameFontHighlight` is white, the engine swaps the highlighted instance in under
the cursor, and `LockHighlight()` holds it there for the selected row. It was a
`$parentName` FontString until decision 1605, on this file's own deviation 7 note that
`Button:SetTextColor`/`LockHighlight` had no engine method — long since untrue.
TradeSkillFrame.xml's row template is the twin, and both keep their own column geometry
(LEFT+4 / 180×16 here) rather than the base's auto-size, so the switch moved no pixel. -->
<ButtonText name="$parentText">
<Size><AbsDimension x="180" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>
<HighlightFont inherits="GameFontHighlight" justifyH="LEFT"/>
<DisabledFont inherits="GameFontDisable" justifyH="LEFT"/>
<Scripts>
<OnLoad>
self.subtext = getglobal(self:GetName() .. "SubText")
</OnLoad>
<OnClick>BenillaCraftSkillButton_OnClick(self, arg1)</OnClick>
</Scripts>
</Button>
<!--
One reagent slot. The ref's `CraftItemTemplate` (l.29-53) inherits `QuestItemTemplate` and
overrides **only scripts** — same as TradeSkillFrame's `TradeSkillItemTemplate`, so the two
windows' slots really are the byte-identical layout this comment always claimed; what they
share is QuestItemTemplate's shape, which neither window had until B250 (decision 1595).
A 39×39 icon, the `UI-QuestItemNameFrame` plate riding its right edge, the name centred ON the
plate, and the "have /need" count on the icon's own BOTTOMRIGHT. This file keeps its OWN copy
(StackSplit.xml's "not a cross-file inherits" note), kept in sync by hand.
-->
<Button name="BenillaCraftReagentTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="147" y="41"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon">
<Size><AbsDimension x="39" y="39"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
</Texture>
<!-- Declared AFTER the icon (same parent+level ⇒ z-tiebreak): the plate's 128×64
texture starts 10px INSIDE the icon's right edge, but its art only begins 11
texels in, so the visible plate opens 1px clear of the icon. -->
<Texture name="$parentNameFrame" file="Interface\QuestFrame\UI-QuestItemNameFrame">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentIcon" relativePoint="RIGHT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="$parentName" inherits="GameFontHighlight" justifyH="LEFT">
<Size><AbsDimension x="90" y="36"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentNameFrame"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
<Layer level="ARTWORK">
<FontString name="$parentCount" inherits="NumberFontNormal" justifyH="RIGHT">
<Anchors><Anchor point="BOTTOMRIGHT" relativeTo="$parentIcon"><Offset><AbsDimension x="-4" y="1"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnEnter>BenillaCraftReagent_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Button>
<!-- A panel action button (Create/Exit): the UIPanelButtonTemplate face locally reconstructed
(this house dialect ships no such template — TalentFrame.xml's own ENGINE API note),
TradeSkillFrame.xml's identical-shaped BenillaTradeSkillPanelButtonTemplate — its OWN local
copy per this house's per-window convention (StackSplit.xml's own "not a cross-file inherits"
note), not a cross-file reuse of that other window's virtual template. -->
<Button name="BenillaCraftPanelButtonTemplate" virtual="true">
<Size><AbsDimension x="80" y="22"/></Size>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
</Button>
<Frame name="BenillaCraftFrame" hidden="true">
<!-- The real 384×512 window. No <Anchors> — ShowUIPanel/SetLeftFrame SetPoints it to the left
slot (MerchantFrame.xml/TradeSkillFrame.xml's own convention). -->
<Size><AbsDimension x="384" y="512"/></Size>
<!-- Transcribed for fidelity (ref l.65-67); no loader wiring exists yet for the hit-rect
shrink — TalentFrame.xml's own "transcribe the hook even if nothing drives it yet"
posture. -->
<HitRectInsets><AbsInset left="0" right="34" top="0" bottom="75"/></HitRectInsets>
<Layers>
<!-- "player" portrait 60×60 TOPLEFT (7,-6) (ref l.70-81), BACKGROUND so the window art's
ring frames it. Bound via SetPortraitTexture(region, "player") in Show/OnEvent — not an
NPC session (header comment). -->
<Layer level="BACKGROUND">
<Texture name="BenillaCraftFramePortrait">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<!-- The four quadrant slabs that ARE the window art (ref l.83-125): ALL FOUR from
ClassTrainerFrame's shared art — unlike TradeSkill's own mixed set (its BotLeft comes
from TradeSkillFrame's own art instead), Craft's ref uses one uniform art family
throughout, transcribed exactly as given. -->
<Layer level="BORDER">
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopLeft"><Size><AbsDimension x="256" y="256"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopRight"><Size><AbsDimension x="128" y="256"/></Size><Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-BotLeft"><Size><AbsDimension x="256" y="256"/></Size><Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-BotRight"><Size><AbsDimension x="128" y="256"/></Size><Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"/></Anchors></Texture>
<!-- Title: GetCraftName() directly — unlike TradeSkill's "%s"-formatted TRADE_SKILL_TITLE,
Craft's ref sets this raw (ref l.101: CraftFrameTitleText:SetText(GetCraftName())).
"Enchanting" is a harmless static placeholder until the first paint. -->
<FontString name="BenillaCraftFrameTitleText" inherits="GameFontNormal" justifyH="CENTER" text="Enchanting">
<Anchors><Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
<!-- The skill-border + horizontal-divider decorative bars (ref l.126-174), sampled from the
same two shared ClassTrainer/TradeSkill textures by TexCoord — byte-identical to
TradeSkillFrame.xml's own equivalents. -->
<Layer level="ARTWORK">
<Texture name="BenillaCraftSkillBorderLeft" file="Interface\TradeSkillFrame\UI-TradeSkill-SkillBorder">
<Size><AbsDimension x="256" y="8"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="63" y="-50"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="1.0" top="0" bottom="0.25"/>
</Texture>
<Texture name="BenillaCraftSkillBorderRight" file="Interface\TradeSkillFrame\UI-TradeSkill-SkillBorder">
<Size><AbsDimension x="28" y="8"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BenillaCraftSkillBorderLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="0.109375" top="0.25" bottom="0.5"/>
</Texture>
<Texture name="BenillaCraftHorizontalBarLeft" file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
<Size><AbsDimension x="256" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="15" y="-221"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="1.0" top="0" bottom="0.25"/>
</Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
<Size><AbsDimension x="75" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BenillaCraftHorizontalBarLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="0.29296875" top="0.25" bottom="0.5"/>
</Texture>
</Layer>
<!-- The FIXED detail-pane FontStrings + header art (ref CraftDetailScrollChildFrame,
l.456-537), flattened onto this window's OWN Layers at OVERLAY (the flat-layout gap,
this file's header comment) — computed from the ref's ScrollFrame anchor (TOPLEFT
20,-234, byte-identical to TradeSkillFrame's own) + each region's own ScrollChild-
relative offset. Declaration order matters (same parent+level ⇒ z-tiebreak,
TradeSkillFrame.xml's own note): the header art is declared BEFORE ReagentLabel so the
"Reagents: " banner text paints over its lower edge, exactly as the reference does.
CraftCost (ref l.483-491, the per-recipe training-point cost line) is DROPPED — deviation 6,
always blank in v1. -->
<Layer level="OVERLAY">
<FontString name="BenillaCraftName" inherits="GameFontNormal" justifyH="LEFT">
<Size><AbsDimension x="244" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="70" y="-239"/></Offset></Anchor></Anchors>
</FontString>
<!-- ONE concatenated FontString (Craft's own ref, unlike TradeSkill's split
Label+Text pair — header comment). -->
<FontString name="BenillaCraftRequirements" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraftName" relativePoint="BOTTOMLEFT"/></Anchors>
</FontString>
<Texture name="BenillaCraftDetailHeaderLeft" file="Interface\ClassTrainerFrame\UI-ClassTrainer-DetailHeaderLeft">
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="20" y="-231"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-DetailHeaderRight">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraftDetailHeaderLeft" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<!-- The recipe's own description (ref l.516-527) — TradeSkill's detail pane has no
analogue for this region (header comment). Its BOTTOMLEFT is the ReagentLabel's
DYNAMIC anchor target, re-pointed every SetSelection (SetSelection's own Lua). -->
<FontString name="BenillaCraftDescription" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size><AbsDimension x="290" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="25" y="-284"/></Offset></Anchor></Anchors>
</FontString>
<!-- STATIC always-shown label (TradeSkillFrame.xml's own precedent: the ref's
Show/Hide-on-reagent-count dance is dropped, every recipe here has ≥1 reagent in
practice) — its POSITION is dynamic (SetSelection), its text/visibility is not. -->
<FontString name="BenillaCraftReagentLabel" inherits="GameFontNormalSmall" text="Reagents: ">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraftDescription" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The top-right close X (ref a UIPanelCloseButton at TOPRIGHT -29,-8, l.688-696; the
minimize-button art stands in — this house dialect ships no such template,
TradeSkillFrame.xml's own precedent). -->
<Button name="BenillaCraftFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="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>BenillaCraftFrameCloseButton_OnClick()</OnClick></Scripts>
</Button>
<!-- The skill-rank StatusBar (ref CraftRankFrame, l.196-264): blue fill, "name rank/max"
text, the UI-Character-Skills-Bar[Border] art — byte-identical to TradeSkillFrame's own.
SKILL_LINES_CHANGED self-listen dropped (deviation 4). -->
<StatusBar name="BenillaCraftRankFrame" drawLayer="BACKGROUND" minValue="0" maxValue="1" defaultValue="0">
<Size><AbsDimension x="268" y="15"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="73" y="-37"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<FontString name="BenillaCraftRankFrameSkillName" inherits="GameFontNormalSmall">
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="6" y="1"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaCraftRankFrameSkillRank" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size><AbsDimension x="128" y="0"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BenillaCraftRankFrameSkillName" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
<Layer level="BACKGROUND">
<Texture name="BenillaCraftRankFrameBackground"><Color r="1.0" g="1.0" b="1.0" a="0.2"/></Texture>
</Layer>
</Layers>
<Frames>
<Button name="BenillaCraftRankFrameBorder">
<Size><AbsDimension x="281" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\PaperDollInfoFrame\UI-Character-Skills-BarBorder"/>
</Button>
</Frames>
<BarTexture name="BenillaCraftRankFrameBar" file="Interface\PaperDollInfoFrame\UI-Character-Skills-Bar"/>
<BarColor r="0.25" g="0.25" b="0.75"/>
</StatusBar>
<!-- The expand/collapse-all tab (ref CraftExpandButtonFrame, l.265-325): verbatim
geometry/art — TradeSkillFrame.xml's own identical tab frame, EXCEPT the button itself
is 22×22 here (Craft's own ref value, not TradeSkill's 40×22 — each window's own ref,
not copy-pasted). TrainerFrame.xml's own BenillaTrainerCollapseAllButton is the
reconstruction precedent this mirrors (standalone Button, not this file's row template —
header comment, deviation 1). Always ends up Hidden in v1 (Update()'s own tail, header
comment) — ships for fidelity, not yet reachable. -->
<Frame name="BenillaCraftExpandButtonFrame">
<Size><AbsDimension x="54" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="15" y="-71"/></Offset></Anchor></Anchors>
<Layers><Layer level="BACKGROUND">
<Texture name="BenillaCraftExpandTabLeft" file="Interface\ClassTrainerFrame\UI-ClassTrainer-ExpandTab-Left"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors></Texture>
<Texture name="BenillaCraftExpandTabMiddle" file="Interface\QuestFrame\UI-QuestLogSortTab-Middle"><Size><AbsDimension x="38" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="BenillaCraftExpandTabLeft" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
<Texture file="Interface\QuestFrame\UI-QuestLogSortTab-Right"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="BenillaCraftExpandTabMiddle" relativePoint="RIGHT"/></Anchors></Texture>
</Layer></Layers>
<Frames>
<Button name="BenillaCraftCollapseAllButton" text="All">
<Size><AbsDimension x="22" y="22"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BenillaCraftExpandTabLeft" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-MinusButton-Up"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"/></Anchors></NormalTexture>
<HighlightTexture file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"/></Anchors></HighlightTexture>
<ButtonText><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="18" y="0"/></Offset></Anchor></Anchors></ButtonText>
<NormalFont inherits="GameFontNormalSmall" justifyH="LEFT"/>
<Scripts>
<OnClick>BenillaCraftCollapseAllButton_OnClick(self)</OnClick>
</Scripts>
</Button>
</Frames>
</Frame>
<!-- The persistent selection highlight (ref CraftHighlightFrame, l.331-343): 293×16,
ARTWORK-layer UI-Listbox-Highlight2. Declared BEFORE the rows so the row text paints
OVER the glow (TradeSkillFrame.xml's own z-tiebreak note). Vertex-colored by the
selected recipe's CraftTypeColor (SetSelection), positioned onto the selected visible
row (Update). -->
<Frame name="BenillaCraftHighlightFrame" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers><Layer level="ARTWORK">
<Texture name="BenillaCraftHighlight" file="Interface\Buttons\UI-Listbox-Highlight2">
<Anchors><Anchor point="TOPLEFT"/><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
</Layer></Layers>
</Frame>
<!-- 8 list rows (ref Craft1..8, l.344-415): 293×16, row1 TOPLEFT (22,-96); rows 2-8 chain
TOPLEFT→prior BOTTOMLEFT with no gap — byte-identical to TradeSkillFrame's own. -->
<Button name="BenillaCraft1" inherits="BenillaCraftSkillButtonTemplate">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-96"/></Offset></Anchor></Anchors>
</Button>
<Button name="BenillaCraft2" inherits="BenillaCraftSkillButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraft1" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaCraft3" inherits="BenillaCraftSkillButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraft2" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaCraft4" inherits="BenillaCraftSkillButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraft3" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaCraft5" inherits="BenillaCraftSkillButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraft4" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaCraft6" inherits="BenillaCraftSkillButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraft5" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaCraft7" inherits="BenillaCraftSkillButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraft6" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaCraft8" inherits="BenillaCraftSkillButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraft7" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<!-- The faux-scroll frame (FauxScrollFrameTemplate, ScrollTemplates.xml): invisible
container over the 8 rows; only its right-hand Slider scroll bar renders. Sized to the
8-row span (CRAFTS_DISPLAYED × CRAFT_SKILL_HEIGHT), TradeSkillFrame.xml's own
same-TOPLEFT-as-row1 convention. updateFunc set in BenillaCraftFrame_OnLoad. -->
<ScrollFrame name="BenillaCraftListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-96"/></Offset></Anchor></Anchors>
</ScrollFrame>
<!-- The detail icon (ref CraftIcon, l.540-577): a 37×37 button over a 64×64 UI-EmptySlot
ring (TrainerFrame.xml's own BenillaTrainerSkillIcon shape, TradeSkillFrame.xml's own
identical reuse) at the flattened header art's top-left. No made-count overlay — Craft
has no min/max-made concept (craft.rs's own module doc), unlike TradeSkill's own icon. -->
<Button name="BenillaCraftIcon" hidden="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="28" y="-237"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\Buttons\UI-EmptySlot"><Size><AbsDimension x="64" y="64"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="-13" y="13"/></Offset></Anchor></Anchors></Texture>
</Layer>
</Layers>
<Scripts>
<OnEnter>BenillaCraftIcon_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Button>
<!-- Up to 8 reagents, the ref's own 2-column grid + anchor chain (l.578-649): odd slots
chain down the left column (TOPLEFT→prior BOTTOMLEFT, -2 gap), even slots sit LEFT of
the odd slot in the same row. Reagent7 chains off Reagent6 (not Reagent5) — a literal
ref quirk shared with TradeSkillFrame's own reagent grid (this file's header comment,
deviation 9's sibling note): harmless, since 6's row-mate LEFT anchor to 5 already puts
it at 5's own row height. Reagent1's own offset is (0,-3) here — Craft's ref literal
value, NOT TradeSkill's own (-5,-3) (transcribed from each window's own ref, not
copy-pasted). -->
<Button name="BenillaCraftReagent1" inherits="BenillaCraftReagentTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraftReagentLabel" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaCraftReagent_OnLoad(self, 1)</OnLoad></Scripts>
</Button>
<Button name="BenillaCraftReagent2" inherits="BenillaCraftReagentTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaCraftReagent1" relativePoint="RIGHT"/></Anchors>
<Scripts><OnLoad>BenillaCraftReagent_OnLoad(self, 2)</OnLoad></Scripts>
</Button>
<Button name="BenillaCraftReagent3" inherits="BenillaCraftReagentTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraftReagent1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaCraftReagent_OnLoad(self, 3)</OnLoad></Scripts>
</Button>
<Button name="BenillaCraftReagent4" inherits="BenillaCraftReagentTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaCraftReagent3" relativePoint="RIGHT"/></Anchors>
<Scripts><OnLoad>BenillaCraftReagent_OnLoad(self, 4)</OnLoad></Scripts>
</Button>
<Button name="BenillaCraftReagent5" inherits="BenillaCraftReagentTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraftReagent3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaCraftReagent_OnLoad(self, 5)</OnLoad></Scripts>
</Button>
<Button name="BenillaCraftReagent6" inherits="BenillaCraftReagentTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaCraftReagent5" relativePoint="RIGHT"/></Anchors>
<Scripts><OnLoad>BenillaCraftReagent_OnLoad(self, 6)</OnLoad></Scripts>
</Button>
<Button name="BenillaCraftReagent7" inherits="BenillaCraftReagentTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCraftReagent6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaCraftReagent_OnLoad(self, 7)</OnLoad></Scripts>
</Button>
<Button name="BenillaCraftReagent8" inherits="BenillaCraftReagentTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaCraftReagent7" relativePoint="RIGHT"/></Anchors>
<Scripts><OnLoad>BenillaCraftReagent_OnLoad(self, 8)</OnLoad></Scripts>
</Button>
<!-- The action row (ref l.654-696): Create / Exit only — NO Create All, NO -/+, NO
repeat-count EditBox (deviation 8). Positions byte-identical to TradeSkillFrame's own
Create/Cancel buttons (ref l.658-663/676-681 vs Craft's own l.658-663/675-680 — the
SAME two constants in both refs). -->
<Button name="BenillaCraftCreateButton" inherits="BenillaCraftPanelButtonTemplate" text="Create">
<Anchors><Anchor point="CENTER" relativeTo="BenillaCraftFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="224" y="-422"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaCraftCreateButton_OnClick()</OnClick></Scripts>
</Button>
<Button name="BenillaCraftCancelButton" inherits="BenillaCraftPanelButtonTemplate" text="Exit">
<Anchors><Anchor point="CENTER" relativeTo="BenillaCraftFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="305" y="-422"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaCraftCancelButton_OnClick()</OnClick></Scripts>
</Button>
</Frames>
<!-- The window lifecycle (MerchantFrame.xml/TradeSkillFrame.xml's own shape): OnHide calls
CloseCraft() for ANY hide reason. The list wheel lives on the WINDOW (the engine bubbles a
spin up the parent chain). -->
<Scripts>
<OnLoad>BenillaCraftFrame_OnLoad(self)</OnLoad>
<OnEvent>BenillaCraftFrame_OnEvent(self)</OnEvent>
<OnMouseWheel>BenillaCraftFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>PlaySound("igCharacterInfoOpen")</OnShow>
<OnHide>CloseCraft(); PlaySound("igCharacterInfoClose")</OnHide>
</Scripts>
</Frame>
</Ui>
-310
View File
@@ -1,310 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The death arc's UI (decision 0308 slices 1/3, plus 1746's self-resurrect): our death
dialogs, registered on the shared StaticPopup engine (UiPanels.xml), plus the event driver
that raises and clears them.
The patch-chain FrameXML (mpqx extraction, decision 0246) is the SPECIFICATION for what
each dialog says, offers and answers — StaticPopup.lua l.375-505 (DEATH + RESURRECT×3),
l.1134-1201 (XP_LOSS×2 + RECOVER_CORPSE*), UIParent.lua l.237-262 + l.399-436 (the event
arms). The field names below are the published registry API that addons write into, so
they are the reference's and cannot be renamed; the code that fills them is ours. Six of
the eight dialogs share one disposition and are stamped with it rather than repeating it,
the three resurrect offers and the two spirit-healer confirms are each one shape built
more than once, and the driver is a single event switch. Strings are the reference's own
(GlobalStrings.lua), which is data, not authorship.
The engine-side events feeding this: PLAYER_DEAD/ALIVE/UNGHOST +
RESURRECT_REQUEST + CONFIRM_XP_LOSS fire from `death.rs`'s state machine;
CORPSE_IN_RANGE/OUT_OF_RANGE/IN_INSTANCE join with the slice-2 corpse-run range gate
(registered now — they simply don't fire yet). Deferred with 0308 §8: the SKINNED/
SKINNED_REPOP pair (battleground insignia).
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim; %% escapes as shipped) ─────────────────────────
local DEATH_RELEASE_TIMER = "%d %s until release"
local DEATH_RELEASE_NOTIMER = "You have died. Release to the nearest graveyard?"
local DEATH_RELEASE = "Release Spirit"
-- Vestigial on this build, and kept anyway because it is what the reference registers:
-- HasSoulstone() returns a Spell.dbc NAME whenever it returns anything, and OnShow stamps
-- that over button2 — so the only case this default could render is one that cannot
-- happen (DisplayButton2 shows the button on exactly the same call). It is the pre-1.6
-- wording, from before "Self-resurrection spells show their name on the button" (decision
-- 1746); GlobalStrings still ships it with the comment "Use your soulstone if you have one".
local USE_SOULSTONE = "Reincarnate"
local RECOVER_CORPSE_TEXT = "Resurrect now?"
local RECOVER_CORPSE_TIMER = "%d %s until resurrection"
local RECOVER_CORPSE_INSTANCE_TEXT = "You must enter the instance to recover your corpse"
local RESURRECT_REQUEST_TEXT = "%s wants to resurrect you. You will be afflicted with resurrection sickness."
local RESURRECT_REQUEST_TIMER = "%s wants to resurrect you and will be able to in %d %s. You will be afflicted with resurrection sickness."
local RESURRECT_REQUEST_NO_SICKNESS = "%s wants to resurrect you"
local RESURRECT_REQUEST_NO_SICKNESS_TIMER = "%s wants to resurrect you and will be able to in %d %s"
local CONFIRM_XP_LOSS = "If you find your corpse, you can resurrect for no penalty. If I resurrect you all of your items will take 25%% durability damage (equipped and inventory) and you will be afflicted by %s of Resurrection Sickness."
local CONFIRM_XP_LOSS_AGAIN = "Remember, if you find your corpse there is no penalty. Are you sure you want to incur %s of Resurrection Sickness and have all your items take 25%% durability damage?"
local CONFIRM_XP_LOSS_NO_SICKNESS = "If you find your corpse, you can resurrect for no penalty. If I resurrect you all of your items will take 25%% durability damage (equipped and inventory)."
local CONFIRM_XP_LOSS_AGAIN_NO_SICKNESS = "Remember, if you find your corpse there is no penalty. Are you sure you want to have all your items take 25% durability damage?"
local ACCEPT = "Accept"
local DECLINE = "Decline"
local CANCEL = "Cancel"
-- ── The corpse-side disposition ──────────────────────────────────────────────────────────
-- Six of these eight dialogs are about a body on the floor, and every one of them wants
-- the same three things from the engine: it must be legal to raise while you are dead
-- (`whileDead` — that is the whole point of the arc), it takes precedence over a running
-- cinematic, and a logout must not quietly close it out from under you. Stamped on here
-- instead of retyped six times; the fields still land on the entry, so an addon reading
-- StaticPopupDialogs["DEATH"].whileDead sees exactly what the reference's does.
local function register_corpse_dialog(which, entry)
entry.whileDead = 1
entry.interruptCinematic = 1
entry.notClosableByLogout = 1
StaticPopupDialogs[which] = entry
end
-- How long a resurrect offer stands once it becomes answerable. It is both the engine's
-- own countdown (`timeout`) and the value OnShow re-seeds past the recovery delay.
local RESURRECT_OFFER_SECONDS = 60
-- ── The release dialog (spec: StaticPopup.lua l.375-414) ─────────────────────────────────
-- OnShow seeds `timeleft` from GetReleaseTimeRemaining(), which the engine renders through
-- its per-tick DEATH text; −1 means this map has no forced release (an instance), so the
-- static wording goes in and nothing counts down. `timeout = 0` because the countdown is
-- the SERVER's to enforce — it force-releases at 6:00 (0308 §1) — and there is deliberately
-- no hideOnEscape: you cannot dismiss dying. Button2 appears only when the server owes us
-- a self-resurrect (PLAYER_SELF_RES_SPELL non-zero); without one a cancel falls through
-- to the release.
register_corpse_dialog("DEATH", {
text = DEATH_RELEASE_TIMER,
button1 = DEATH_RELEASE,
button2 = USE_SOULSTONE,
DisplayButton2 = function()
return HasSoulstone()
end,
OnShow = function()
local name = this:GetName()
this.timeleft = GetReleaseTimeRemaining()
if this.timeleft == -1 then
getglobal(name .. "Text"):SetText(DEATH_RELEASE_NOTIMER)
end
-- The self-res names its own button, out of Spell.dbc: "Use Soulstone" (3026,
-- 20758-20761), "Reincarnation" (21169), "Twisting Nether" (23700).
local soulstone = HasSoulstone()
if soulstone then
getglobal(name .. "Button2"):SetText(soulstone)
end
end,
OnAccept = function()
RepopMe()
end,
OnCancel = function(data, reason)
-- Only a real click on button2 is an answer. "override" and "timeout" are the
-- engine taking the dialog away, and neither is the player choosing anything.
if reason ~= "clicked" then
return
end
-- Button2 is the self-resurrect (decision 1746): CMSG_SELF_RES, and the server
-- casts whatever PLAYER_SELF_RES_SPELL holds. Re-asked rather than remembered
-- from OnShow, exactly as the reference does — the field is the server's and can
-- have been spent since the dialog opened.
if HasSoulstone() then
UseSoulstone()
else
RepopMe()
end
end,
timeout = 0,
cancels = "RECOVER_CORPSE",
})
-- ── At the corpse (spec: StaticPopup.lua l.1182-1202) — slice 2's range gate raises these.
-- Accept is gated behind the recovery delay and, once taken, the dialog STAYS: reclaiming
-- is the server's to confirm, and it does so with the descriptor deltas that stand you up.
register_corpse_dialog("RECOVER_CORPSE", {
StartDelay = GetCorpseRecoveryDelay,
delayText = RECOVER_CORPSE_TIMER,
text = RECOVER_CORPSE_TEXT,
button1 = ACCEPT,
OnAccept = function()
RetrieveCorpse()
return 1
end,
timeout = 0,
})
-- Your body is inside a dungeon you are standing outside of: a notice, not an offer, so
-- it carries no button at all.
register_corpse_dialog("RECOVER_CORPSE_INSTANCE", {
text = RECOVER_CORPSE_INSTANCE_TEXT,
timeout = 0,
})
-- ── Somebody else's offer (spec: StaticPopup.lua l.416-505) ──────────────────────────────
-- Three registry keys, one shape: with sickness, without, and without a recovery timer to
-- wait out. They differ only in the wording and in whether the offer is delay-gated, so
-- they are built rather than written three times.
--
-- Refusing an offer (or letting it lapse) has to undo the `cancels = "DEATH"` that raising
-- it did — otherwise a declined resurrect leaves you dead with nothing on screen to
-- release from.
local function restore_release_dialog()
if UnitIsDead("player") then
StaticPopup_Show("DEATH")
end
end
local function resurrect_offer(offer_text, delay_text)
return {
text = offer_text,
button1 = ACCEPT,
button2 = DECLINE,
OnAccept = function()
AcceptResurrect()
end,
OnCancel = function()
DeclineResurrect()
restore_release_dialog()
end,
-- The countdown. Given a `delay_text` the offer is gated: Accept stays dead and
-- that text counts the corpse-recovery cooldown down; without one (the no-timer
-- variant) Accept is live at once. Either way the offer lapses a minute after it
-- becomes answerable, which is why OnShow re-seeds `timeleft` past the gate
-- instead of leaving `timeout`'s minute to start the moment it appears.
StartDelay = delay_text and GetCorpseRecoveryDelay or nil,
delayText = delay_text,
OnShow = function()
this.timeleft = GetCorpseRecoveryDelay() + RESURRECT_OFFER_SECONDS
end,
timeout = RESURRECT_OFFER_SECONDS,
cancels = "DEATH",
hideOnEscape = 1,
}
end
register_corpse_dialog("RESURRECT",
resurrect_offer(RESURRECT_REQUEST_TEXT, RESURRECT_REQUEST_TIMER))
register_corpse_dialog("RESURRECT_NO_SICKNESS",
resurrect_offer(RESURRECT_REQUEST_NO_SICKNESS, RESURRECT_REQUEST_NO_SICKNESS_TIMER))
register_corpse_dialog("RESURRECT_NO_TIMER",
resurrect_offer(RESURRECT_REQUEST_NO_SICKNESS))
-- ── The spirit healer's price (spec: StaticPopup.lua l.1134-1180) ────────────────────────
-- Both variants ask twice. The first Accept rewrites the dialog to a blunter second ask
-- and clears `data`; the truthy return keeps it open. The second Accept — now with no data
-- — sends the answer. So `data` is at once the payload and the "which ask is this" flag,
-- which is why the driver below hands the no-sickness variant a bare 1.
--
-- The second ask is a FUNCTION, not a string, because only one of the two has anything to
-- substitute: the no-sickness wording carries a bare "25% durability" that format() would
-- read as a conversion and choke on.
--
-- Neither offer outlives walking away from the healer, so both poll the distance.
local function close_when_out_of_reach(elapsed, dialog)
if not CheckSpiritHealerDist() then
dialog:Hide()
end
end
local function spirit_healer_confirm(first_ask, second_ask)
return {
text = first_ask,
button1 = ACCEPT,
button2 = CANCEL,
OnAccept = function(data)
if not data then
AcceptXPLoss()
return
end
-- `this` is the button the engine clicked; the dialog is its parent.
local dialog = this:GetParent()
getglobal(dialog:GetName() .. "Text"):SetText(second_ask(data))
dialog.data = nil
return 1
end,
OnUpdate = close_when_out_of_reach,
timeout = 0,
exclusive = 1,
whileDead = 1,
showAlert = 1,
hideOnEscape = 1,
}
end
StaticPopupDialogs["XP_LOSS"] = spirit_healer_confirm(CONFIRM_XP_LOSS, function(sickness)
return format(CONFIRM_XP_LOSS_AGAIN, sickness)
end)
StaticPopupDialogs["XP_LOSS_NO_SICKNESS"] = spirit_healer_confirm(
CONFIRM_XP_LOSS_NO_SICKNESS,
function()
return CONFIRM_XP_LOSS_AGAIN_NO_SICKNESS
end)
-- ── The driver (spec: UIParent.lua l.237-262 + l.399-436) ───────────────────────────────
-- One switch over the arc's events. The reference spreads these arms through the whole of
-- UIParent_OnEvent; ours has a frame of its own (below) and nothing else to share it with.
function BenillaDeathDriver_OnEvent()
if event == "PLAYER_DEAD" then
if not StaticPopup_Visible("DEATH") then
CloseAllWindows(1)
if GetReleaseTimeRemaining() > 0 or GetReleaseTimeRemaining() == -1 then
StaticPopup_Show("DEATH")
end
end
elseif event == "PLAYER_ALIVE" then
StaticPopup_Hide("DEATH")
StaticPopup_Hide("RESURRECT_NO_SICKNESS")
elseif event == "PLAYER_UNGHOST" then
StaticPopup_Hide("RESURRECT")
StaticPopup_Hide("RESURRECT_NO_SICKNESS")
StaticPopup_Hide("RESURRECT_NO_TIMER")
elseif event == "RESURRECT_REQUEST" then
if ResurrectHasSickness() then
StaticPopup_Show("RESURRECT", arg1)
elseif ResurrectHasTimer() then
StaticPopup_Show("RESURRECT_NO_SICKNESS", arg1)
else
StaticPopup_Show("RESURRECT_NO_TIMER", arg1)
end
elseif event == "CONFIRM_XP_LOSS" then
-- If the healer would charge resurrection sickness, its duration goes into the
-- text; if not there is nothing to quote, and the bare 1 handed over instead is
-- only the "this is still the first ask" marker the two-step above reads.
local sickness = GetResSicknessDuration()
local dialog
if sickness then
dialog = StaticPopup_Show("XP_LOSS", sickness)
else
dialog = StaticPopup_Show("XP_LOSS_NO_SICKNESS")
end
if dialog then
dialog.data = sickness or 1
end
-- The confirm covers the healer's gossip window (spec: UIParent.lua l.412).
local gossip = getglobal("GossipFrame")
if gossip and gossip:IsVisible() then
HideUIPanel(gossip)
end
elseif event == "CORPSE_IN_RANGE" then
StaticPopup_Show("RECOVER_CORPSE")
elseif event == "CORPSE_IN_INSTANCE" then
StaticPopup_Show("RECOVER_CORPSE_INSTANCE")
elseif event == "CORPSE_OUT_OF_RANGE" then
StaticPopup_Hide("RECOVER_CORPSE")
StaticPopup_Hide("RECOVER_CORPSE_INSTANCE")
StaticPopup_Hide("XP_LOSS")
end
end
]]></Script>
<Frame name="BenillaDeathDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("PLAYER_DEAD")
self:RegisterEvent("PLAYER_ALIVE")
self:RegisterEvent("PLAYER_UNGHOST")
self:RegisterEvent("RESURRECT_REQUEST")
self:RegisterEvent("CONFIRM_XP_LOSS")
self:RegisterEvent("CORPSE_IN_RANGE")
self:RegisterEvent("CORPSE_IN_INSTANCE")
self:RegisterEvent("CORPSE_OUT_OF_RANGE")
</OnLoad>
<OnEvent>BenillaDeathDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
@@ -1,318 +0,0 @@
<Ui>
<!--
The dressing room (decision 1060) — ref `DressUpFrame.xml` / `DressUpFrame.lua`, quoted.
CTRL-left-click an item anywhere (bags, the paper doll, a chat link, the bank) and it lands
on your own character here, over the gear you are actually wearing. The window's own
instruction line says exactly that, in Blizzard's words:
`DRESSUP_FRAME_INSTRUCTIONS` = "CTRL-Left Click additional items to display them on your
character" (ref-GlobalStrings.lua:944) — which is also where the modifier is settled: CTRL
previews, SHIFT posts the link to chat (`ContainerFrame.lua:565-571`).
THE MODEL PANE is a booth-texture stand-in, not the ref's live `<DressUpModel>` widget —
the settled doctrine (0105/0118) and exactly what the character window's own pane does
(`CharacterFrame.xml`'s header). So the ref's three model METHODS have no widget to live on
and become the engine globals `BenillaDressUpModel_Dress/TryOn/Close/SetFacing`
(`benilla-ui/src/script/dressup.rs` has the why); `DressUpItem`/`DressUpItemLink` keep their
real global names, because every ctrl-click site in the UI calls those.
Departures, each named:
- No drag (the ref is `movable` with StartMoving/StopMovingOrSizing): benilla has no
user-moved-frame affordance yet — the window sits in the left UIPanel slot, which the ref
registers it into too (`UIParent.lua:41`, transcribed in UiPanels.xml).
- `DressUpModel:SetUnit("player")` and `:Dress()` are ONE verb here
(`BenillaDressUpModel_Dress`): our model is always the player's, so "point it at the
player" and "put the player's own gear back on it" are the same effect.
- The ref's `scale="2.0"` on the model widget is a 3D-widget zoom with no meaning for a
baked pane — the booth frames the body from its own bounds (`framing::body_frame`), the
same law the paper doll is framed by. The bake is framed at THIS pane's 316×351 aspect,
not square, or the UI's stretch would leave every character 11% too tall (decision 1069).
- The pane ANIMATES, as the ref's live widget does — Stand looping (decision 1069; the look
call 0822 §5 left open, made by the director). Its booth is TRANSPARENT for the same
reason a widget is: the dark room behind the character is the `DressUpBackground-<Race>`
art below, and Reset/Close sit on that art *inside* this pane's rect (both anchor sets
transcribed byte-identically from the ref, which is why the overlap is the ref's too).
-->
<Script><![CDATA[
-- GlobalStrings this window uses (ref-GlobalStrings.lua, cited per line; the
-- `X = X or "..."` idiom is this codebase's convention — CharacterFrame.xml/InspectFrame.xml).
DRESSUP_FRAME = DRESSUP_FRAME or "Dressing Room" -- :943
DRESSUP_FRAME_INSTRUCTIONS = DRESSUP_FRAME_INSTRUCTIONS
or "CTRL-Left Click additional items to display them on your character" -- :944
RESET = RESET or "Reset" -- :3340
CLOSE = CLOSE or "Close" -- :760
-- ref DressUpFrame.lua:2-8. The `not IsVisible()` gate is the reference's own state reset:
-- opening the room fresh re-dresses the model in the player's real gear, and only then
-- tries the clicked item on. Both halves ride ONE ordered intent queue app-side, so the
-- reset can never overtake the try-on (dressup.rs's module doc).
function DressUpItem(item)
if not DressUpFrame:IsVisible() then
ShowUIPanel(DressUpFrame)
BenillaDressUpModel_Dress() -- ref: DressUpModel:SetUnit("player")
end
BenillaDressUpModel_TryOn(item) -- ref: DressUpModel:TryOn(item)
end
-- ref DressUpFrame.lua:10-16, verbatim: pull the bare item id out of the `|Hitem:<id>:…`
-- payload and hand THAT to TryOn. Every ctrl-click site in the UI calls this one function,
-- which is why it keeps the reference's global name.
function DressUpItemLink(link)
if not link then
return
end
local item = gsub(link, ".*item:(%d+).*", "%1", 1)
DressUpItem(tonumber(item))
end
-- ref DressUpFrame.lua:18-32 including its own "-- HACK!!!" comment: gnomes borrow the
-- dwarf backdrop and trolls the orc one (there is no Gnome/Troll art), and an unknown race
-- falls back to Orc. Quoted, not corrected.
function DressUpTexturePath()
local race, fileName = UnitRace("player")
if fileName == "Gnome" or fileName == "GNOME" then
fileName = "Dwarf"
elseif fileName == "Troll" or fileName == "TROLL" then
fileName = "Orc"
end
if not fileName then
fileName = "Orc"
end
return "Interface\\DressUpFrame\\DressUpBackground-" .. fileName
end
-- ref DressUpFrame.lua:34-40. Called on OnShow as well as OnLoad: at load time the app has
-- usually not pushed the player's unit snapshot yet, so `UnitRace` answers nil and the ref's
-- own fallback would freeze an Orc backdrop onto every character (the same one-tick nil the
-- character window's level line documents).
function SetDressUpBackground()
local texture = DressUpTexturePath()
DressUpBackgroundTopLeft:SetTexture(texture .. 1)
DressUpBackgroundTopRight:SetTexture(texture .. 2)
DressUpBackgroundBotLeft:SetTexture(texture .. 3)
DressUpBackgroundBotRight:SetTexture(texture .. 4)
end
-- The pane's facing — the paper doll's pair of handlers, over this room's own booth yaw
-- (ref Model_OnLoad/Model_RotateLeft/Model_RotateRight/Model_OnUpdate, UIParent.lua:1421-1462;
-- CharacterFrame.xml carries the full note on the ref's sign quirk, quoted here too).
function BenillaDressUpModel_OnLoad(frame)
frame.facing = 0.61 -- ref UIParent.lua:1422, the literal default facing
BenillaDressUpModel_SetFacing(frame.facing)
end
function BenillaDressUpModel_RotateLeft(frame)
frame.facing = frame.facing - 0.03
BenillaDressUpModel_SetFacing(frame.facing)
PlaySound("igInventoryRotateCharacter")
end
function BenillaDressUpModel_RotateRight(frame)
frame.facing = frame.facing + 0.03
BenillaDressUpModel_SetFacing(frame.facing)
PlaySound("igInventoryRotateCharacter")
end
-- The held-button spin (ref Model_OnUpdate) — half a turn per second while a rotate button
-- reads PUSHED, the ref's own opposite-sign held branches included.
function BenillaDressUpModel_OnUpdate(frame, elapsed)
local step = elapsed * 2 * PI * ROTATIONS_PER_SECOND
if getglobal(frame:GetName() .. "RotateLeftButton"):GetButtonState() == "PUSHED" then
frame.facing = frame.facing + step
if frame.facing < 0 then
frame.facing = frame.facing + (2 * PI)
end
BenillaDressUpModel_SetFacing(frame.facing)
end
if getglobal(frame:GetName() .. "RotateRightButton"):GetButtonState() == "PUSHED" then
frame.facing = frame.facing - step
if frame.facing > (2 * PI) then
frame.facing = frame.facing - (2 * PI)
end
BenillaDressUpModel_SetFacing(frame.facing)
end
end
]]></Script>
<!-- A panel action button (Reset/Close): the ref's UIPanelButtonTemplate face, locally
reconstructed — this house dialect ships no such template, and each window keeps its own
copy (CraftFrame.xml's identical note). -->
<Button name="BenillaDressUpPanelButtonTemplate" virtual="true">
<Size><AbsDimension x="80" y="22"/></Size>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
</Button>
<Frame name="DressUpFrame" enableMouse="true" parent="UIParent" toplevel="true" hidden="true">
<!-- The ref's 384×512 window. No <Anchors>: ShowUIPanel seats it in the left slot, this
house's convention for every panel window (MerchantFrame.xml/CraftFrame.xml). -->
<Size><AbsDimension x="384" y="512"/></Size>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="45"/></HitRectInsets>
<Layers>
<!-- The "player" portrait, 60×60 at TOPLEFT (7,-6) (ref l.20-33) — BACKGROUND so the
window art's ring frames it; bound in OnShow, like every other portrait here. -->
<Layer level="BACKGROUND">
<Texture name="DressUpFramePortrait">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<!-- The window art: the general-panel top pair + the skill-frame bottom pair, the ref's
own four slabs (l.34-72). -->
<Layer level="ARTWORK">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-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\PaperDollInfoFrame\SkillFrame-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\PaperDollInfoFrame\SkillFrame-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="256" y="-256"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="DressUpFrameTitleText" inherits="GameFontHighlight" text="DRESSUP_FRAME">
<Size><AbsDimension x="300" y="14"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="DressUpFrameDescriptionText" inherits="GameFontNormalSmall" text="DRESSUP_FRAME_INSTRUCTIONS">
<Size><AbsDimension x="260" y="36"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="DressUpFrameTitleText" relativePoint="BOTTOM"><Offset><AbsDimension x="10" y="-22"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
<!-- The race backdrop behind the model, four quadrants (ref l.73-118); the files are
chosen per race by SetDressUpBackground above. -->
<Layer level="OVERLAY">
<Texture name="DressUpBackgroundTopLeft">
<Size><AbsDimension x="256" y="255"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="22" y="-76"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="DressUpBackgroundTopRight">
<Size><AbsDimension x="62" y="255"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="DressUpBackgroundTopLeft" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="DressUpBackgroundBotLeft">
<Size><AbsDimension x="256" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="DressUpBackgroundTopLeft" relativePoint="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture name="DressUpBackgroundBotRight">
<Size><AbsDimension x="62" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="DressUpBackgroundTopLeft" relativePoint="BOTTOMRIGHT"/></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The corner close X: the ref's UIPanelCloseButton seat (CENTER of TOPRIGHT, -46,-25 —
ref l.120-127) on the minimize-button art every close button in this house uses. -->
<Button name="DressUpFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-46" y="-25"/></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>HideUIPanel(DressUpFrame)</OnClick>
</Scripts>
</Button>
<!-- Close (ref l.128-146) and Reset (l.147-165, chained off Close's LEFT). Reset is the
ref's `DressUpModel:Dress()` + its gsTitleOptionOK click. -->
<Button name="DressUpFrameCancelButton" inherits="BenillaDressUpPanelButtonTemplate" text="CLOSE">
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="305" y="-422"/></Offset></Anchor></Anchors>
<Scripts>
<OnClick>HideUIPanel(DressUpFrame)</OnClick>
</Scripts>
</Button>
<Button name="DressUpFrameResetButton" inherits="BenillaDressUpPanelButtonTemplate" text="RESET">
<Anchors><Anchor point="RIGHT" relativeTo="DressUpFrameCancelButton" relativePoint="LEFT"/></Anchors>
<Scripts>
<OnClick>
BenillaDressUpModel_Dress()
PlaySound("gsTitleOptionOK")
</OnClick>
</Scripts>
</Button>
<!-- The model pane (ref the <DressUpModel> widget, l.166-207): 316×351 anchored BOTTOM
(-11,+85). A plain Frame sampling the "dressup" booth bake (header). -->
<Frame name="DressUpModelFrame">
<Size><AbsDimension x="316" y="351"/></Size>
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="-11" y="85"/></Offset></Anchor></Anchors>
<Layers>
<!-- BACKGROUND, not ARTWORK — the booth quad is an OPAQUE stand-in for a widget
the reference renders outside the layer batches, so it must sit as low as a
region goes or it swallows same-level siblings (decision 1070; the pet
page's diet icon is where that bit). This window is the one where the
change is visible: the CLOSE/RESET buttons are declared BEFORE the pane and
overlap its lower edge, so at ARTWORK the pane was painting over them. -->
<Layer level="BACKGROUND">
<Texture name="$parentTexture">
<Size><AbsDimension x="316" y="351"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- Rotate buttons (ref l.183-206): 35×35 at the WINDOW's TOPLEFT (21,-75), left
then right chained off its TOPRIGHT. Both edges registered, so one tap fires
OnClick twice and holding hands the spin to the pane's OnUpdate — the ref's
own tap-and-hold (decision 0638 §3). -->
<Button name="DressUpModelFrameRotateLeftButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="DressUpFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="21" y="-75"/></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>self:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
<OnClick>BenillaDressUpModel_RotateLeft(getglobal("DressUpModelFrame"))</OnClick>
</Scripts>
</Button>
<Button name="DressUpModelFrameRotateRightButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="DressUpModelFrameRotateLeftButton" 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>self:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
<OnClick>BenillaDressUpModel_RotateRight(getglobal("DressUpModelFrame"))</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>
BenillaDressUpModel_OnLoad(self)
BenillaSetBoothTexture(getglobal(self:GetName() .. "Texture"), "dressup")
</OnLoad>
<OnUpdate>BenillaDressUpModel_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<Scripts>
<OnLoad>SetDressUpBackground()</OnLoad>
<OnShow>
SetDressUpBackground()
SetPortraitTexture(DressUpFramePortrait, "player")
PlaySound("igCharacterInfoOpen")
</OnShow>
<!-- Closing empties the booth (dressup.rs's `Close` intent): the ref's widget keeps its
state while hidden, but its next DressUpItem re-issues SetUnit("player") *because*
the frame wasn't visible, so that state is never observable. -->
<OnHide>
BenillaDressUpModel_Close()
PlaySound("igCharacterInfoClose")
</OnHide>
</Scripts>
</Frame>
</Ui>
@@ -1,94 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The duel UI (decision 0633): the two duel dialogs on our shared StaticPopup engine
(UiPanels.xml) plus the duel-event driver. The patch-chain FrameXML (mpqx extraction,
decision 0246) is the SPECIFICATION — StaticPopup.lua l.1103-1120 (DUEL_REQUESTED +
DUEL_OUTOFBOUNDS), UIParent.lua l.98-101 (the registrations) + l.378-393 (the event arms)
— and what it fixes is contract: the two dialog keys, the StaticPopupDialogs field names,
the sound kit and the two timeouts are all addressed by name, so they are reproduced
exactly while the entries around them are written here in our own shape. The strings are
the shipped enUS values from GlobalStrings.lua, quoted as data.
Duels have no window of their own — these two dialogs ARE the whole duel UI. Everything
else is engine-side: the "Duel starting: N" countdown and the outcome line are
CHAT_MSG_SYSTEM lines `crate::ui_duel` composes (byte-verified: WoW.exe's own duel TU emits
both through the chat path with type 0x0A, not through any frame), and the four events
below are fired from that module's state edges.
The DUEL_OUTOFBOUNDS dialog is text-only — no buttons, timeout 10 — and its text is filled
by the StaticPopup engine's per-tick countdown branch, which already names it in both of
its special-case lists (UiPanels.xml: the show-time " " seeding and the OnUpdate re-format).
The 10 s is the client's dialog timeout, and it coincides with the server's own forfeit
window (`Player::CheckDuelDistance`) rather than driving it: the popup expiring is cosmetic,
the forfeit is the server's.
-->
<Script><![CDATA[
-- ── Strings (the shipped enUS GlobalStrings.lua values, quoted as data) ─────────────────
local DUEL_REQUESTED = "%s has challenged you to a duel." -- l.957
local DUEL_OUTOFBOUNDS_TIMER = "Exiting duel area, you will forfeit in %d %s." -- l.956
local ACCEPT = "Accept"
local DECLINE = "Decline"
-- ── The challenge dialog (spec: StaticPopup.lua l.1103-1116) ────────────────────────────
-- Written scalars-first, handlers-last: what the dialog IS, then what answering it does.
--
-- No accept-guard flag (unlike PARTY_INVITE): the entry declines only on an explicit
-- OnCancel, so a timeout or an override hides it WITHOUT sending anything. That is not an
-- oversight to "fix" — the server's own 60 s is not a thing either; an unanswered challenge
-- simply sits until one side acts.
StaticPopupDialogs["DUEL_REQUESTED"] = {
text = DUEL_REQUESTED,
button1 = ACCEPT,
button2 = DECLINE,
sound = "igPlayerInvite",
timeout = 60,
hideOnEscape = 1,
-- The two buttons are the two replies. ESC arrives here as well — hideOnEscape routes
-- an escape through OnCancel — so backing out and declining are deliberately one path.
OnAccept = function()
AcceptDuel()
end,
OnCancel = function()
CancelDuel()
end,
}
-- ── The out-of-bounds warning (spec: StaticPopup.lua l.1117-1120) ───────────────────────
-- Two fields and nothing else: no buttons, because there is nothing to answer, and no
-- text of its own — the engine's countdown branch writes it every tick.
StaticPopupDialogs["DUEL_OUTOFBOUNDS"] = {
text = DUEL_OUTOFBOUNDS_TIMER,
timeout = 10,
}
-- ── The duel event arms (spec: UIParent.lua l.378-393) ──────────────────────────────────
function BenillaDuelDriver_OnEvent()
if event == "DUEL_REQUESTED" then
StaticPopup_Show("DUEL_REQUESTED", arg1)
elseif event == "DUEL_OUTOFBOUNDS" then
StaticPopup_Show("DUEL_OUTOFBOUNDS")
elseif event == "DUEL_INBOUNDS" then
StaticPopup_Hide("DUEL_OUTOFBOUNDS")
elseif event == "DUEL_FINISHED" then
-- Both, unconditionally: a duel that ends while the challenge popup is still up
-- (the other side cancelled first) must not leave it there.
StaticPopup_Hide("DUEL_REQUESTED")
StaticPopup_Hide("DUEL_OUTOFBOUNDS")
end
end
]]></Script>
<Frame name="BenillaDuelDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("DUEL_REQUESTED")
self:RegisterEvent("DUEL_OUTOFBOUNDS")
self:RegisterEvent("DUEL_INBOUNDS")
self:RegisterEvent("DUEL_FINISHED")
</OnLoad>
<OnEvent>BenillaDuelDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
@@ -1,93 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The two enchant-apply confirms (decision 0928): registry entries on the shared StaticPopup
engine (UiPanels.xml) plus their UIParent event arms, transcribed from the patch-chain
FrameXML (mpqx extraction, decision 0246) — StaticPopup.lua l.1240-1251 (BIND_ENCHANT) and
l.1252-1263 (REPLACE_ENCHANT), UIParent.lua l.110-111 (the registrations), l.437-442 (the
show arms) and l.449-453 (the CURRENT_SPELL_CAST_CHANGED hide). Strings verbatim from
GlobalStrings.lua l.415 / l.3324.
They are NOT the Craft window's, which is why they live in their own file and load with the
shared dialogs rather than with CraftFrame.xml: the gate that raises them (`0x495d60`) is
the generic item-target bind, so a rogue applying a poison and a shaman imbuing a mace
reach them without any profession window open at all.
Both are pure client-side gating. There is no CMSG_REPLACE_ENCHANT in 5875 (wow-re's
tradeskill node: no CDataStore put/Send anywhere in `0x48d2e0`/`0x48d300`/`0x495d60`
/`0x6e5b40` outside the shared SendCast) — the popups withhold the *same* cast and their
Yes re-issues it. `BindEnchant()` re-enters the gate with its confirmed flag set, so a Yes
here can raise the replace popup next; `ReplaceEnchant()` skips the gate entirely.
The third sibling, TRADE_REPLACE_ENCHANT (event 404, fired at `0x496230` with a third
numeric arg), belongs to the trade-window enchant path — a different wire shape
(TARGET_FLAG_TRADE_ITEM + the slot sentinel) we do not build yet, so it is not here.
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim) ───────────────────────────────────────────────
local BIND_ENCHANT = "Enchanting this item will bind it to you." -- l.415
local REPLACE_ENCHANT = "Do you want to replace \"%s\" with \"%s\"?" -- l.3324
local OKAY = "Okay"
local CANCEL = "Cancel"
local YES = "Yes"
local NO = "No"
-- ── The bind confirm — StaticPopup.lua l.1240-1251, verbatim ───────────────────────────
-- No OnCancel: declining sends nothing and needs no teardown, because the gate returned
-- before BindTarget and the targeting word is still standing. The cursor simply stays up.
StaticPopupDialogs["BIND_ENCHANT"] = {
text = BIND_ENCHANT,
button1 = OKAY,
button2 = CANCEL,
OnAccept = function()
BindEnchant()
end,
timeout = 0,
exclusive = 1,
showAlert = 1,
hideOnEscape = 1,
}
-- ── The replace confirm — StaticPopup.lua l.1252-1263, verbatim ────────────────────────
-- arg1 = the enchant already on the item, arg2 = the one about to replace it, in that
-- order (the fire site pushes old then new: `0x703f50(0x193, "%s%s", old, new)`).
StaticPopupDialogs["REPLACE_ENCHANT"] = {
text = REPLACE_ENCHANT,
button1 = YES,
button2 = NO,
OnAccept = function()
ReplaceEnchant()
end,
timeout = 0,
exclusive = 1,
showAlert = 1,
hideOnEscape = 1,
}
-- ── The UIParent event arms (UIParent.lua l.437-453, verbatim) ─────────────────────────
function BenillaEnchantConfirmDriver_OnEvent()
if event == "BIND_ENCHANT" then
StaticPopup_Show("BIND_ENCHANT")
elseif event == "REPLACE_ENCHANT" then
StaticPopup_Show("REPLACE_ENCHANT", arg1, arg2)
elseif event == "CURRENT_SPELL_CAST_CHANGED" then
-- The pending cast the popups are asking about is gone (cancelled, replaced, or
-- committed), so the question is moot. This is the ref's ONLY teardown for them.
StaticPopup_Hide("BIND_ENCHANT")
StaticPopup_Hide("REPLACE_ENCHANT")
end
end
]]></Script>
<Frame name="BenillaEnchantConfirmDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("BIND_ENCHANT")
self:RegisterEvent("REPLACE_ENCHANT")
self:RegisterEvent("CURRENT_SPELL_CAST_CHANGED")
</OnLoad>
<OnEvent>BenillaEnchantConfirmDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
-397
View File
@@ -1,397 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
Benilla's named virtual Font objects: the COMPLETE set from the real 1.12.1 FrameXML
`Fonts.xml`, quoted verbatim — names, TTF paths, heights, colors, outlines, and
(critically) which families carry a `<Shadow>` and which don't. The reference copy is
re-extractable from the client at any time with the `mpqx` bin (crates/benilla-mpq,
virtual path `Interface\FrameXML\Fonts.xml`); this is our own authored file (MIT/Apache),
only the TTFs themselves stream from the client at render.
Being COMPLETE is the point: the first partial transcription lacked `QuestFontNormalSmall`,
a window substituted `GameFontNormalSmall` for it, and the parchment objectives rendered
10px gold WITH the MasterFont drop shadow instead of 12px warm-brown WITHOUT one — a look
bug born entirely from a missing font object, invisible to every engine test. Substituting
"a similar font" is never safe (shadow/height/color all ride the name), so every name the
real FrameXML defines exists here.
A FontString picks a font object up with `inherits="GameFontNormal"`; `SetFontObject`
re-points it. Each object resolves {font path, height, color, outline, shadow} through its
`inherits` chain. NOTE the load-bearing asymmetry, straight from the ref: the `GameFont*`
families chain through `MasterFont` (whose only content is the (1,-1) black `<Shadow>`),
while `GameFontBlack`/`GameFontWhite`, the `NumberFont*` family (outlined instead), and the
parchment `QuestFont*` family (except the brown-shadowed `QuestTitleFont`) do NOT — dark
text on parchment is shadowless in the real client.
-->
<!-- The shared font-color globals, QUOTED from the real Fonts.xml's own <Script> block
(ref l.8-19) — the |cAARRGGBB escape prefixes the stat/tooltip builders concatenate, and
the two color tables tooltip AddLine calls read. Defined here (the ref's own home, loaded
first) so every window shares one verified set. -->
<Script><![CDATA[
-- ── The four font-PATH globals (ref Fonts.xml l.4-7) ──────────────────────────────────
-- This file quoted the reference's colour block (l.8-19) and skipped the four lines above
-- it. `STANDARD_TEXT_FONT` is the one that matters: **68 corpus addons name it**, and per
-- 1207's rule that number is read rather than quoted — 64 of them are copies of
-- `Dewdrop-2.0.lua` and only 4 are addon code. But Dewdrop is *the* Ace2 menu library, and
-- it calls `button.text:SetFont(STANDARD_TEXT_FONT, height)` **unguarded** at two of its
-- three sites (l.207 and l.496; only l.2294 writes `STANDARD_TEXT_FONT or "Fonts\\..."`),
-- so a nil global is a failed SetFont on every menu button the Ace2 ecosystem draws.
--
-- The other three come with it because they are the same four-line block and cost nothing:
-- UNIT_NAME_FONT (0 corpus addons), DAMAGE_TEXT_FONT (1), NAMEPLATE_FONT (1). All four are
-- the same face in 1.12 — the reference assigns FRIZQT__ to every one — and that identity
-- is the reference's, not a simplification of ours.
STANDARD_TEXT_FONT = "Fonts\\FRIZQT__.TTF" -- ref Fonts.xml l.4
UNIT_NAME_FONT = "Fonts\\FRIZQT__.TTF" -- l.5
DAMAGE_TEXT_FONT = "Fonts\\FRIZQT__.TTF" -- l.6
NAMEPLATE_FONT = "Fonts\\FRIZQT__.TTF" -- l.7
NORMAL_FONT_COLOR_CODE = "|cffffd200" -- ref Fonts.xml l.8
HIGHLIGHT_FONT_COLOR_CODE = "|cffffffff" -- l.9
RED_FONT_COLOR_CODE = "|cffff2020" -- l.10
GREEN_FONT_COLOR_CODE = "|cff20ff20" -- l.11
GRAY_FONT_COLOR_CODE = "|cff808080" -- l.12
-- l.13, and it was the ONE line of this block that was missing — the comments beside it
-- even skipped from `l.12` to `l.14`. `aux-addon.lua:7` concatenates it at file scope
-- (`LIGHTYELLOW_FONT_COLOR_CODE .. "aux: " ..`), so its absence was not a wrong colour, it
-- was the addon dying on its own first line.
LIGHTYELLOW_FONT_COLOR_CODE = "|cffffff9a" -- l.13
FONT_COLOR_CODE_CLOSE = "|r" -- l.14
NORMAL_FONT_COLOR = { r = 1.0, g = 0.82, b = 0 } -- l.15
HIGHLIGHT_FONT_COLOR = { r = 1.0, g = 1.0, b = 1.0 } -- l.16
GRAY_FONT_COLOR = { r = 0.5, g = 0.5, b = 0.5 } -- l.17
GREEN_FONT_COLOR = { r = 0.1, g = 1.0, b = 0.1 } -- l.18
RED_FONT_COLOR = { r = 1.0, g = 0.1, b = 0.1 } -- l.19
-- Added this session (decision 0216 §8, the spellbook slice) — the ref's own l.20, the
-- one color this file was missing: a passive spell's name text (SpellBookFrame.lua:385).
PASSIVE_SPELL_FONT_COLOR = { r = 0.77, g = 0.64, b = 0 } -- l.20
-- The item-quality palette — **built from the engine verb, exactly as the reference
-- builds it** (ref UIParent.lua l.65-72, verbatim shape; decision 1199):
--
-- ITEM_QUALITY_COLORS = { }
-- for i = -1, 6 do … = GetItemQualityColor(i) end
--
-- It used to be seven hand-typed rows here. They were CLOSE and not exact — 0.62 where
-- the client's byte is 0x9d/255 = 0.6157, 0.64/0.21/0.93 where Epic is
-- 0xa3/0x35/0xee — and they carried no `.hex`, which the reference's table has and
-- which addons splice straight into a chat link. `GetItemQualityColor` is now a real
-- engine binding over wow-5875-re RF-0055's byte-verified table, so the rounding and
-- the second source both go.
--
-- The loop starts at -1 like the reference's, and the -1 row is not a curiosity: it is the
-- colour a loot row wears while its item template is uncached, which is the value
-- `GetLootSlotInfo` answers there. `GetItemQualityColor`'s clamp is UNSIGNED, so -1 lands
-- on Common/white with 7-and-up (decision 1805, correcting 1199's "reads before the
-- array").
ITEM_QUALITY_COLORS = { }
for i = -1, 6 do
ITEM_QUALITY_COLORS[i] = { }
ITEM_QUALITY_COLORS[i].r,
ITEM_QUALITY_COLORS[i].g,
ITEM_QUALITY_COLORS[i].b,
ITEM_QUALITY_COLORS[i].hex = GetItemQualityColor(i)
end
-- ── RAID_CLASS_COLORS (spec: ref Fonts.xml l.43-53 — same name, same nine keys, same
-- channel values; our own way of writing them down) ───────────────────────────────────
--
-- The per-class colour every raid/party addon paints names with. **22 corpus addons** reach
-- it — nine of them through `Babble-Class-2.0`, but seven more by hand (KLHThreatMeter,
-- CT_RaidAssist, CT_ExpenseHistory, _LazyPig, FuBar_oRA2CooldownFu, and BigWigs in two
-- plugins) — and absent it is `bad argument #1 to 'pairs' (table expected, got nil)` at
-- file scope. That was the whole of the harness's remaining `table expected` row: three
-- addons, one missing table, found by asking the blocker table which addons were behind it.
--
-- Written as the eight distinct triples plus a loop that lifts them into the `{r=,g=,b=}`
-- records addons index (`RAID_CLASS_COLORS["HUNTER"].r`), the same spec-plus-builder shape
-- ITEM_QUALITY_COLORS above takes. The only thing worth SAYING about a palette is which
-- colours are in it, and a bare triple says that with no ceremony; the record shape is the
-- addon-facing contract and belongs in the one line that builds it.
--
-- EIGHT triples for nine classes, and that is the 1.12 client's own doing: shamans have no
-- colour of their own. They were Horde-only and paladins Alliance-only, so the two never
-- shared a raid frame and the client hands SHAMAN the paladin pink. We ASSIGN it, which
-- states the fact instead of re-typing three numbers and asking the next reader to believe
-- a comment. (`ROGUE`'s yellow likewise reconciles with nothing else in the client. A later
-- client's palette is not this one's; these are 1.12's.) Each class still gets its OWN
-- record below, so an addon that writes to `.PALADIN.r` does not move SHAMAN — the
-- reference's two equal-but-separate tables, preserved.
local classRGB = {
WARRIOR = { 0.78, 0.61, 0.43 },
PALADIN = { 0.96, 0.55, 0.73 },
HUNTER = { 0.67, 0.83, 0.45 },
ROGUE = { 1.0, 0.96, 0.41 },
PRIEST = { 1.0, 1.0, 1.0 },
MAGE = { 0.41, 0.8, 0.94 },
WARLOCK = { 0.58, 0.51, 0.79 },
DRUID = { 1.0, 0.49, 0.04 },
}
classRGB.SHAMAN = classRGB.PALADIN
RAID_CLASS_COLORS = {}
for token, rgb in pairs(classRGB) do
RAID_CLASS_COLORS[token] = { r = rgb[1], g = rgb[2], b = rgb[3] }
end
-- CHAT_FONT_HEIGHTS — the chat window's four selectable font heights, the reference's own
-- table verbatim (ref Fonts.xml l.21-26). It sat in the note below as "costs nine lines the
-- day something reads it" until decision 1589 (B246) built the chat tab's **Font Size**
-- submenu, which is the reference's only reader too (`FCFOptionsDropDown_Initialize`
-- iterates it). Still zero corpus addons, so the demand here is ours, not theirs.
CHAT_FONT_HEIGHTS = { 12, 14, 16, 18 }
-- The page ink and the title ink per readable-material, ref Fonts.xml l.27-42 verbatim.
--
-- These were deliberately absent for a long time — "zero corpus addons name either, and
-- nothing benilla ships reads them; they cost nine lines each the day something does".
-- 1751's ninth window is that day: the reference's own `GetMaterialTextColors`
-- (UIParent.lua l.1880-1888) reads them by these names, and it is what the sourced
-- `ItemTextFrame.xml` calls. Our ItemTextFrame carried a private
-- `BENILLA_MATERIAL_*_COLOR_TABLE` pair; both are deleted, and this is where 1.12 keeps
-- the one that remains.
MATERIAL_TEXT_COLOR_TABLE = {
["Default"] = {0.18, 0.12, 0.06},
["Stone"] = {1.0, 1.0, 1.0},
["Parchment"] = {0.18, 0.12, 0.06},
["Marble"] = {0, 0, 0},
["Silver"] = {0.12, 0.12, 0.12},
["Bronze"] = {0.18, 0.12, 0.06},
}
MATERIAL_TITLETEXT_COLOR_TABLE = {
["Default"] = {0, 0, 0},
["Stone"] = {0.93, 0.82, 0},
["Parchment"] = {0, 0, 0},
["Marble"] = {0.93, 0.82, 0},
["Silver"] = {0.93, 0.82, 0},
["Bronze"] = {0.93, 0.82, 0},
}
]]></Script>
<!-- MasterFont: the shadow-only root the GameFont* families chain through (ref l.56). -->
<Font name="MasterFont" virtual="true">
<Shadow>
<Offset><AbsDimension x="1" y="-1"/></Offset>
<Color r="0" g="0" b="0"/>
</Shadow>
</Font>
<!-- SystemFont: FRIZQT, 15px, yellow (ref l.64). -->
<Font name="SystemFont" inherits="MasterFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="15"/></FontHeight>
<Color r="1.0" g="1.0" b="0"/>
</Font>
<!-- The GameFont body family: FRIZQT, 12px, gold; color variants override only <Color> (ref l.70-96). -->
<Font name="GameFontNormal" inherits="MasterFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1.0" g="0.82" b="0"/>
</Font>
<Font name="GameFontHighlight" inherits="GameFontNormal" virtual="true">
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="GameFontDisable" inherits="GameFontNormal" virtual="true">
<Color r="0.5" g="0.5" b="0.5"/>
</Font>
<Font name="GameFontGreen" inherits="GameFontNormal" virtual="true">
<Color r="0.1" g="1.0" b="0.1"/>
</Font>
<Font name="GameFontRed" inherits="GameFontNormal" virtual="true">
<Color r="1.0" g="0.1" b="0.1"/>
</Font>
<!-- GameFontBlack/White: 12px FRIZQT with NO MasterFont chain — shadowless (ref l.88-96). -->
<Font name="GameFontBlack" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="0" g="0" b="0"/>
</Font>
<Font name="GameFontWhite" inherits="GameFontBlack" virtual="true">
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<!-- The GameFont Small family: FRIZQT, 10px, gold (ref l.97-125). -->
<Font name="GameFontNormalSmall" inherits="MasterFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="10"/></FontHeight>
<Color r="1.0" g="0.82" b="0"/>
</Font>
<Font name="GameFontHighlightSmall" inherits="GameFontNormalSmall" virtual="true">
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="GameFontDisableSmall" inherits="GameFontNormalSmall" virtual="true">
<Color r="0.5" g="0.5" b="0.5"/>
</Font>
<Font name="GameFontDarkGraySmall" inherits="GameFontNormalSmall" virtual="true">
<Color r="0.35" g="0.35" b="0.35"/>
</Font>
<Font name="GameFontGreenSmall" inherits="GameFontNormalSmall" virtual="true">
<Color r="0.1" g="1.0" b="0.1"/>
</Font>
<Font name="GameFontRedSmall" inherits="GameFontNormalSmall" virtual="true">
<Color r="1.0" g="0.1" b="0.1"/>
</Font>
<!-- Despite the name, the ref declares a SHADOW here, not an outline (ref l.119). -->
<Font name="GameFontHighlightSmallOutline" inherits="GameFontHighlightSmall" virtual="true">
<Shadow>
<Offset><AbsDimension x="1" y="-1"/></Offset>
<Color r="0" g="0" b="0"/>
</Shadow>
</Font>
<!-- The GameFont Large family: FRIZQT, 16px, gold (ref l.127-145). -->
<Font name="GameFontNormalLarge" inherits="MasterFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="16"/></FontHeight>
<Color r="1.0" g="0.82" b="0"/>
</Font>
<Font name="GameFontHighlightLarge" inherits="GameFontNormalLarge" virtual="true">
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="GameFontDisableLarge" inherits="GameFontNormalLarge" virtual="true">
<Color r="0.5" g="0.5" b="0.5"/>
</Font>
<Font name="GameFontGreenLarge" inherits="GameFontNormalLarge" virtual="true">
<Color r="0.1" g="1.0" b="0.1"/>
</Font>
<Font name="GameFontRedLarge" inherits="GameFontNormalLarge" virtual="true">
<Color r="1.0" g="0.1" b="0.1"/>
</Font>
<!-- FRIZQT, 20px, gold (ref l.145). -->
<Font name="GameFontNormalHuge" inherits="MasterFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="20"/></FontHeight>
<Color r="1.0" g="0.82" b="0"/>
</Font>
<!-- The Number family: ARIALN, outlined, white — NO shadow (ref l.150-179). -->
<Font name="NumberFontNormal" font="Fonts\ARIALN.TTF" outline="NORMAL" virtual="true">
<FontHeight><AbsValue val="14"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="NumberFontNormalYellow" inherits="NumberFontNormal" virtual="true">
<Color r="1.0" g="0.82" b="0"/>
</Font>
<Font name="NumberFontNormalSmall" font="Fonts\ARIALN.TTF" outline="THICK" virtual="true" monochrome="true">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="NumberFontNormalSmallGray" inherits="NumberFontNormalSmall" outline="THICK" virtual="true">
<Color r="0.6" g="0.6" b="0.6"/>
</Font>
<Font name="NumberFontNormalLarge" font="Fonts\ARIALN.TTF" outline="NORMAL" virtual="true">
<FontHeight><AbsValue val="16"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="NumberFontNormalHuge" font="Fonts\skurri.ttf" outline="THICK" virtual="true">
<FontHeight><AbsValue val="30"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<!-- ChatFontNormal: ARIALN, 14px, white, (1,-1) black shadow (ref l.180). -->
<Font name="ChatFontNormal" font="Fonts\ARIALN.TTF" virtual="true">
<FontHeight><AbsValue val="14"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
<Shadow>
<Offset><AbsDimension x="1" y="-1"/></Offset>
<Color r="0" g="0" b="0"/>
</Shadow>
</Font>
<!-- The quest/parchment fonts (ref l.191-215): MORPHEUS 18 title with a warm-brown (0.49,0.35,0.05)
shadow; the FRIZQT body fonts are dark-on-parchment and SHADOWLESS (QuestFontNormalSmall
chains through GameFontBlack, not MasterFont). -->
<Font name="QuestTitleFont" font="Fonts\MORPHEUS.ttf" virtual="true">
<FontHeight><AbsValue val="18"/></FontHeight>
<Color r="0" g="0" b="0"/>
<Shadow>
<Offset><AbsDimension x="1" y="-1"/></Offset>
<Color r="0.49" g="0.35" b="0.05"/>
</Shadow>
</Font>
<Font name="QuestFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="13"/></FontHeight>
<Color r="0" g="0" b="0"/>
</Font>
<Font name="QuestFontNormalSmall" inherits="GameFontBlack" virtual="true">
<Color r="0.30" g="0.18" b="0"/>
</Font>
<Font name="QuestFontHighlight" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="14"/></FontHeight>
<Color r="0" g="0" b="0"/>
</Font>
<!-- The MORPHEUS 15px book/mail body fonts (ref l.216-229). -->
<Font name="ItemTextFontNormal" font="Fonts\MORPHEUS.TTF" justifyH="LEFT" virtual="true">
<FontHeight><AbsValue val="15"/></FontHeight>
<Color r="0.18" g="0.12" b="0.06"/>
</Font>
<Font name="MailTextFontNormal" font="Fonts\MORPHEUS.TTF" justifyH="LEFT" virtual="true">
<FontHeight><AbsValue val="15"/></FontHeight>
<Color r="0.18" g="0.12" b="0.06"/>
</Font>
<!-- SubSpellFont: FRIZQT 10, dark brown (ref l.231). -->
<Font name="SubSpellFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="10"/></FontHeight>
<Color r="0.35" g="0.2" b="0"/>
</Font>
<!-- The dialog (StaticPopup) button pair: FRIZQT 16 (ref l.237-245). -->
<Font name="DialogButtonNormalText" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="16"/></FontHeight>
<Color r="1.0" g="0.82" b="0.0"/>
</Font>
<Font name="DialogButtonHighlightText" inherits="DialogButtonNormalText" virtual="true">
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<!-- The zone-announce banners: THICK-outlined FRIZQT; the ref's bare `<Shadow/>` transcribed
as-is (offset defaults, i.e. no visible offset) (ref l.246-257). -->
<Font name="ZoneTextFont" font="Fonts\FRIZQT__.TTF" outline="THICK" virtual="true">
<FontHeight><AbsValue val="102"/></FontHeight>
<Color r="1.0" g="0.9294" b="0.7607"/>
<Shadow/>
</Font>
<Font name="SubZoneTextFont" font="Fonts\FRIZQT__.TTF" outline="THICK" virtual="true">
<FontHeight><AbsValue val="26"/></FontHeight>
</Font>
<Font name="ErrorFont" inherits="GameFontNormalLarge" virtual="true"/>
<Font name="TextStatusBarText" inherits="NumberFontNormal" virtual="true"/>
<Font name="TextStatusBarTextSmall" font="Fonts\ARIALN.TTF" outline="NORMAL" virtual="true" monochrome="false">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="CombatLogFont" inherits="GameFontHighlight" justifyH="LEFT" virtual="true"/>
<!-- The tooltip family: FRIZQT white, no shadow (ref l.267-285). -->
<Font name="GameTooltipText" font="Fonts\FRIZQT__.TTF" justifyH="LEFT" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="GameTooltipTextSmall" font="Fonts\FRIZQT__.TTF" justifyH="LEFT" virtual="true">
<FontHeight><AbsValue val="10"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="GameTooltipHeaderText" font="Fonts\FRIZQT__.TTF" justifyH="LEFT" virtual="true">
<FontHeight><AbsValue val="14"/></FontHeight>
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="WorldMapTextFont" font="Fonts\FRIZQT__.TTF" outline="THICK" virtual="true">
<FontHeight><AbsValue val="102"/></FontHeight>
<Color r="1.0" g="0.9294" b="0.7607"/>
<Shadow/>
</Font>
<!-- The invoice (auction mail) parchment fonts (ref l.293-304). -->
<Font name="InvoiceTextFontNormal" font="Fonts\FRIZQT__.TTF" justifyH="LEFT" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="0.18" g="0.12" b="0.06"/>
</Font>
<Font name="InvoiceTextFontSmall" font="Fonts\FRIZQT__.TTF" justifyH="LEFT" virtual="true">
<FontHeight><AbsValue val="10"/></FontHeight>
<Color r="0.18" g="0.12" b="0.06"/>
</Font>
<!-- CombatTextFont: FRIZQT 25, gold, MasterFont shadow (ref l.305). -->
<Font name="CombatTextFont" inherits="MasterFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="25"/></FontHeight>
<Color r="1.0" g="0.82" b="0"/>
</Font>
</Ui>
File diff suppressed because it is too large Load Diff
+114 -29
View File
@@ -2,7 +2,7 @@
<!--
The GAME MENU — the frame ESC opens (decision 0674). The last hole in the game shell's
outermost layer: until now ESC's chain ran out of things to close and did nothing, and the
Main Menu micro button was drawn but dead (MicroMenu.xml's SCOPE note).
Main Menu micro button was drawn but dead (our since-retired MicroMenu.xml's SCOPE note).
PROVENANCE (0985 — the three-source split, stated the same way in every era-shaped
window; OptionsFrame.xml carries the sibling block):
@@ -30,22 +30,22 @@
Options", SOUNDOPTIONS_MENU "Sound Options", UIOPTIONS_MENU "Interface Options",
MACROS "Macros", LOGOUT "Logout", EXIT_GAME "Exit Game",
RETURN_TO_GAME "Return to Game", MAIN_MENU "Main Menu". Declared as the real globals
(they are addon-visible API, decision 0068) and read back into each button's label in its
OnLoad — the ref's `text="LOGOUT"` attribute IS a global lookup, which this engine's
literal `text=` is not.
(they are addon-visible API, decision 0068) and carried on each button's own
`text=` attribute — which this engine resolves as a global-string lookup exactly as
the reference does (decision 0995, `loader/widgets.rs`'s `resolve_text`).
· **The sounds** — the ref's own OnClick lines: igMainMenuOption (the five option buttons),
igMainMenuLogout, igMainMenuQuit, igMainMenuContinue. All four kits verified present in
SoundEntries.dbc (patch-2.MPQ) alongside the igMainMenuOpen/Close pair already in use.
· **The two logout dialogs** — ref `StaticPopup.lua` l.564-600 (CAMP + QUIT) is the
SPECIFICATION for them: the two dialog keys, the field names, the 20 s timeout and the
asymmetric button sets are contract, and the entries carrying them are written here in
our own shape on the shared popup engine (UiPanels.xml), whose countdown-text branch
our own shape on the shared popup engine (the stock StaticPopup), whose countdown-text branch
already lists both `which` names. Their driver events are the reference's own
(`re/events/event-catalog.tsv`: 276 PLAYER_CAMPING, 277 PLAYER_QUITING,
278 LOGOUT_CANCEL), fired by `crate::ui_logout` off `SMSG_LOGOUT_RESPONSE`.
NAMES are the reference's, unprefixed (`GameMenuFrame`, `GameMenuButton*`) — the same call
MicroMenu.xml made for its straight transcriptions, and required here besides: `ToggleGameMenu`
our retired MicroMenu.xml made for its straight transcriptions, and required here besides: `ToggleGameMenu`
and `GameMenuFrame` are Era API surface an addon addresses by name.
SCOPE / divergences (noted, not bugs):
@@ -94,14 +94,13 @@
scripts, so it never actually moves).
`frameStrata="DIALOG"` is what puts it over the world UI, and that is kept.
**The "no frame-level raise" half of that reason is gone**: the raise law is built
(`script::object::toplevel`, wow-re `ui/scratch/toplevel-raise.md`) and the loader honours
`toplevel="true"`. Re-adding it here is a one-word change and would make the Esc menu come
to the front over any other DIALOG-strata frame it overlaps; left off pending the
director's call on the look, not on the mechanism.
(`script::object::toplevel`, wow-re `ui/scratch/toplevel-raise.md`), the loader honours
`toplevel="true"`, and the frame below carries it — so the Esc menu does come to the
front over any other DIALOG-strata frame it overlaps.
-->
<Script><![CDATA[
-- The reference GlobalStrings (patch-2.MPQ Interface\FrameXML\GlobalStrings.lua), quoted
-- verbatim. `X = X or "..."` so a later real strings table wins, the MicroMenu.xml idiom.
-- verbatim. `X = X or "..."` so a later real strings table wins, the retired MicroMenu.xml's idiom.
MAIN_MENU = MAIN_MENU or "Main Menu"
HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode"
GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support"
@@ -119,7 +118,99 @@
button:Disable()
end
-- The CAMP/QUIT dialogs and the three-event driver MOVED OUT, to UiPanels.xml — the
-- ══ THE THREE STOCK CONSUMERS THAT ASK "IS AN OPTIONS WINDOW OPEN?" (decision 2177) ══
-- All three are the reference's own, off the chain, and all three name the reference's
-- three windows literally. Two of those names are now REAL hidden stock frames
-- (`SoundOptionsFrame` since 2147, `UIOptionsFrame` since 2115) and the third,
-- `OptionsFrame`, became one when the stock VIDEO window joined the manifest — so the
-- whole chain now reads "no", always, while the window the player actually opens is up.
-- Until then `OptionsFrame` WAS our window and every one of them answered by accident.
--
-- **Wrappers, not replacements.** Each stock function keeps running and keeps deciding;
-- ours adds the one term the reference could not know about. A rewrite would be thirty
-- lines of the reference's ladder copied into our tree to drift.
--
-- **HERE, not in `OptionsFrame.xml`, because of load order.** `UpdateMicroButtons` is
-- defined by `MainMenuBarMicroButtons.xml`, manifest l.576 — AFTER our options window at
-- l.522, where a wrapper would capture a nil. This file is the manifest's LAST row
-- (l.1273), so all three exist. It is also the right home on the merits: this is the ESC
-- menu, and these three are exactly "the ESC ladder and the micro button know about our
-- options window".
local Ref_IsOptionFrameOpen = IsOptionFrameOpen
local Ref_ToggleGameMenu = ToggleGameMenu
local Ref_UpdateMicroButtons = UpdateMicroButtons
-- The one term all three add. Existence-guarded because these three wrappers stand in a
-- file that can be seated without the options window: in the client `BenillaOptionsFrame`
-- is manifest row 38 and this file is the last row, so it is always real — but a test kit
-- that seats the game menu alone must get "no options window is open", not a raise inside
-- the ESC handler.
local function BenillaOptions_IsOpen()
return BenillaOptionsFrame and BenillaOptionsFrame:IsVisible()
end
-- `UIParent.lua:996`. One caller: `CloseAllWindows_WithExceptions`, which passes
-- `ignoreCenter = 1` when an options window is up so the ESC sweep does not close the
-- centre panel out from under it. Ours IS the centre panel (`UIPanelWindows` row,
-- `area = "center"`), so answering "no" would have ESC close it through the sweep instead
-- of through its own rung, discarding pending edits with no Cancel semantics.
function IsOptionFrameOpen()
if BenillaOptions_IsOpen() then
return 1
end
return Ref_IsOptionFrameOpen()
end
-- `UIParent.lua:1465-1497`. The ESC ladder, and the rung matters as much as the answer:
-- the reference tries the static popup FIRST, then an open options window, then the game
-- menu. So this runs that first rung itself and returns if it fired — `StaticPopup_Escape -- Pressed` is a pure scan when nothing is shown (`StaticPopup.lua:1879-1893`), so the
-- delegate below re-running it costs nothing and changes nothing — then takes the options
-- rung, then hands the rest of the ladder back untouched.
--
-- The close is our own Close button's click, which is the same discard-and-hide the
-- reference reaches through `OptionsFrameCancel:Click()`: our `<OnHide>` drops the pending
-- page. (That alias is retired — `OptionsFrameCancel` is the stock video window's own
-- button now, and ours loading later would have clobbered it.)
function ToggleGameMenu(clicked)
if clicked then
-- The reference's `clicked` arm closes an open options window and then goes on to
-- toggle the game menu, rather than returning. Same here.
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
end
return Ref_ToggleGameMenu(clicked)
end
if StaticPopup_EscapePressed() then
return
end
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
return
end
return Ref_ToggleGameMenu(clicked)
end
-- `MainMenuBarMicroButtons.lua:47-58`. A POST-hook that can only ever push the button, never
-- release it: the reference has already decided from the six frames it knows, and this adds
-- the seventh. Written this way round so it cannot fight that decision — a wrapper that also
-- set NORMAL would be a second copy of the reference's whole `or` chain.
--
-- **Existence-guarded, and only here.** The shipped manifest always carries
-- `MainMenuBarMicroButtons.xml`, so in the client `Ref_UpdateMicroButtons` and
-- `MainMenuMicroButton` are always real. A partial test kit that seats this file and not
-- the micro bar is the case the guards are for, and a guard is the honest answer there:
-- there is no micro button to push, so there is nothing to do — as against a raise inside
-- an unrelated window's OnShow, which is what an unguarded wrapper turns that into.
function UpdateMicroButtons()
if Ref_UpdateMicroButtons then
Ref_UpdateMicroButtons()
end
if MainMenuMicroButton and BenillaOptions_IsOpen() then
MainMenuMicroButton:SetButtonState("PUSHED", 1)
end
end
-- The CAMP/QUIT dialogs and the three-event driver MOVED OUT, to our UIParent.lua counterpart (the stock UIParent.lua since 1988) — the
-- reference's own homes for both (StaticPopup.lua l.564/586; UIParent.lua l.304-315).
-- They were here only because this is where they were first needed, and 1751 moving them
-- is one less thing to carry when this window follows them onto the chain.
@@ -164,7 +255,7 @@
<!-- Era swaps the header text to 13px (MainMenuFrameMixin:OnLoad →
SetHeaderFont(GameFontNormalMed1)); the seat is the 1.12 one (within half a
unit of era's — header SCOPE note). -->
<FontString name="GameMenuFrameTitle" inherits="GameFontNormalMed1">
<FontString name="GameMenuFrameTitle" inherits="GameFontNormalMed1" text="MAIN_MENU">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuFrameHeader"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor>
</Anchors>
@@ -180,20 +271,19 @@
something exists behind them. -->
<!-- Options — live: the era GameMenuFrame.lua l.88 entry (ShowUIPanel(SettingsPanel)
there, OptionsFrame here) plus an explicit HideUIPanel first. The ref leans on
there, BenillaOptionsFrame here) plus an explicit HideUIPanel first. The ref leans on
the center-replacement rule for the swap (ShowUIPanel l.697-702: native-center
frames replace each other); hiding the menu explicitly says the same thing
without relying on the eviction path. -->
<Button name="GameMenuButtonOptions" inherits="GameMenuButtonTemplate">
<Button name="GameMenuButtonOptions" inherits="GameMenuButtonTemplate" text="Options">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="28" y="-32"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText("Options")</OnLoad>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(GameMenuFrame)
ShowUIPanel(OptionsFrame)
ShowUIPanel(BenillaOptionsFrame)
</OnClick>
</Scripts>
</Button>
@@ -210,14 +300,13 @@
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, GAMEMENU_SUPPORT)</OnLoad></Scripts>
</Button>
<Button name="GameMenuButtonMacros" inherits="GameMenuButtonTemplate">
<Button name="GameMenuButtonMacros" inherits="GameMenuButtonTemplate" text="MACROS">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonSupport" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<!-- LIVE since decision 0983 (MacroFrame.xml). The ref's own body
(GameMenuFrame.xml l.118-122): hide the menu, then ShowMacroFrame(). -->
<Scripts>
<OnLoad>self:SetText(MACROS)</OnLoad>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(getglobal("GameMenuFrame"))
@@ -230,12 +319,11 @@
button while either logout dialog is already up (you cannot re-arm a running
countdown); it runs on the menu's own show, which is when a re-opened menu has to
re-read that state. -->
<Button name="GameMenuButtonLogout" inherits="GameMenuButtonTemplate">
<Button name="GameMenuButtonLogout" inherits="GameMenuButtonTemplate" text="LOGOUT">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonMacros" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(LOGOUT)</OnLoad>
<OnShow>
if not StaticPopup_Visible("CAMP") and not StaticPopup_Visible("QUIT") then
self:Enable()
@@ -250,12 +338,11 @@
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonQuit" inherits="GameMenuButtonTemplate">
<Button name="GameMenuButtonQuit" inherits="GameMenuButtonTemplate" text="EXIT_GAME">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonLogout" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(EXIT_GAME)</OnLoad>
<OnShow>
if not StaticPopup_Visible("CAMP") and not StaticPopup_Visible("QUIT") then
self:Enable()
@@ -270,12 +357,11 @@
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonContinue" inherits="GameMenuButtonTemplate">
<Button name="GameMenuButtonContinue" inherits="GameMenuButtonTemplate" text="RETURN_TO_GAME">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonQuit" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(RETURN_TO_GAME)</OnLoad>
<OnClick>
PlaySound("igMainMenuContinue")
HideUIPanel(GameMenuFrame)
@@ -286,11 +372,10 @@
<!-- ref GameMenuFrame OnShow/OnHide, verbatim: the micro row's Main Menu button pushes in
while the menu is up, and the bag bar greys out under it (you cannot open bags from
behind the menu — ShowUIPanel's center gate would refuse the window anyway, so the
greying is the visible half of a rule the panel manager already enforces). Guarded, the
UiPanels.xml `CloseMenus and CloseMenus()` posture: a headless test loads this file
without the action bar behind it. -->
greying is the visible half of a rule the panel manager already enforces). Guarded, in
the `CloseMenus and CloseMenus()` posture our retired panel file used: a headless test
loads this file without the action bar behind it. -->
<Scripts>
<OnLoad>GameMenuFrameTitle:SetText(MAIN_MENU)</OnLoad>
<OnShow>
-- The shared era-window scale first (ERA_WINDOW_SCALE, OptionsFrame.xml — loaded
-- before this file): the era menu has no scale of its own (GameMenuFrame.xml +
@@ -1,622 +0,0 @@
<Ui>
<!--
The GameTooltip (decision 0274): a real <GameTooltip> engine widget — the line stack,
SetOwner anchor law, auto-size + right-flush, IsOwned, FadeOut, and the OnTooltipCleared/
OnTooltipAddMoney script protocol all live engine-side (benilla-ui script/tooltip.rs); the
line pairs are engine-created named regions (GameTooltipTextLeftN/RightN — real globals,
like the template pairs reference Lua addresses by name). This file declares the two
TEMPLATES and their instances: the plate (backdrop art + colors QUOTED from
ref-GameTooltip.xml/.lua, line cites inline), the money coin trio, and the Lua that is
genuinely FrameXML's in the real client (GameTooltip_SetDefaultAnchor,
GameTooltip_AddNewbieTip, SetTooltipMoney) — plus the P0 item-stat renderer, which
moves engine-side in 0274 P1 with the full template feed.
The frame's global name is the real one — GameTooltip — so transcribed reference code
(MinimapCluster's zone hover, UnitFrame's TextLeft1 recolor) works verbatim.
The reference splits this across two files — Interface\FrameXML\GameTooltipTemplate.xml
holds the virtual GameTooltipTemplate (l.3-627) and ShoppingTooltipTemplate (l.628-1217);
GameTooltip.xml <Include>s it and declares only the instances. Both templates live here
instead, ABOVE their instances, because that is the whole of their reach: the tooltip file
already loads before every window that hovers, and keeping them in one file means the
template is registered by the time the instances (and, later, ItemRef.xml's) expand it
without a second manifest entry to keep in order. Same registry, same splice, one file.
-->
<Script><![CDATA[
-- Tooltip default colours (VERIFIED, ref-GameTooltip.lua l.3-4). The border is white, the
-- background the dark blue tint; applied to the Backdrop in OnLoad exactly as the real
-- GameTooltip_OnLoad does (ref-GameTooltip.lua l.79-82).
TOOLTIP_DEFAULT_COLOR = { r = 1, g = 1, b = 1 }
TOOLTIP_DEFAULT_BACKGROUND_COLOR = { r = 0.09, g = 0.09, b = 0.19 }
-- GameTooltip_SetDefaultAnchor's position math (ref-GameTooltip.lua l.6,73-77): the
-- bottom-right screen corner, -CONTAINER_OFFSET_X - 13 in x, CONTAINER_OFFSET_Y in y.
DEFAULT_TOOLTIP_POSITION = -13
-- The shopping-compare pair count (ref GameTooltip.lua l.7; PaperDollFrame's listener
-- bounds arg2 by it).
NUM_SHOPPING_TOOLTIPS = 2
-- "Show Detailed Tooltips" — ref UIOptionsFrame.lua l.100-101, inside UIOptionsFrame_Init:
-- SHOW_NEWBIE_TIPS = "1"; RegisterForSave("SHOW_NEWBIE_TIPS");
-- It is a FrameXML *uvar*, not a CVar, and 1.12 ships it ON — so the detailed second line
-- IS the default reference tooltip, not an opt-in. (An earlier note in this corpus had this
-- backwards; decision 0661.) The options window's Interface page drives it now
-- (decision 1136), and the assignment below is the ONLY statement of the default: the saved
-- chunk executes over it at load (1128) and the row captures it as its Defaults value at
-- OnLoad — which runs first — so "1" is written down in exactly one place, here.
SHOW_NEWBIE_TIPS = "1"
RegisterForSave("SHOW_NEWBIE_TIPS")
-- The bracketed name out of an item hyperlink "|cff…|Hitem:117|h[Tough Jerky]|h|r"; if the
-- string has no bracket it is already a plain name (the merchant/loot rows hand plain names).
function BenillaTooltip_ItemName(link)
if not link then return nil end
local _, _, name = strfind(link, "%[(.-)%]")
return name or link
end
-- The shared quality→colour table, `ITEM_QUALITY_COLORS` — Fonts.xml's, matching
-- `ui_items.rs`'s `quality_color` and the client's `GetItemQualityColor`. Unknown/nil
-- quality falls back to common white. All FrameXML files load before any hover fires, so
-- the table is defined by the time this runs regardless of file order.
--
-- This read `BENILLA_LOOT_QUALITY_COLORS` until 1751 took the loot window to the player's
-- chain. That name was only ever an alias our own `LootFrame.xml` set to this same table
-- ("the local name kept for this file's existing readers"), and the stock file does not
-- set it — so the alias went from harmless indirection to a nil the moment its definer
-- stopped shipping. The `t and` guard would have made that silent: every tooltip would
-- have quietly gone white.
function BenillaTooltip_QualityColor(quality)
local t = ITEM_QUALITY_COLORS
local c = (t and (t[quality] or t[1])) or { r = 1, g = 1, b = 1 }
return c.r, c.g, c.b
end
-- Copper → "Ng Ms Kc" text for a price/value line (only the non-zero high coins, always the
-- copper). math.floor if present, else a truncation fallback.
function BenillaTooltip_MoneyString(copper)
copper = copper or 0
local mfloor = (math and math.floor) or function(x) return x - (x % 1) end
local g = mfloor(copper / 10000)
local s = mfloor((copper - g * 10000) / 100)
local c = copper - g * 10000 - s * 100
local out = ""
if g > 0 then out = g .. "g " end
if s > 0 or g > 0 then out = out .. s .. "s " end
return out .. c .. "c"
end
-- The default anchor (ref-GameTooltip.lua l.73-77, quoted): owner with no auto-anchor,
-- then the bottom-right screen corner, above the bag row - CONTAINER_OFFSET_X/Y are the
-- manage pass's live values (UIParent.xml). The engine's ANCHOR_NONE drops the previous
-- owner anchor (tooltip.rs SetOwner), so no ClearAllPoints - the ref has none either.
function GameTooltip_SetDefaultAnchor(tooltip, parent)
tooltip:SetOwner(parent, "ANCHOR_NONE")
-- Read the two managed globals FRESH, and fall back AT USE rather than assigning a
-- copy: this file used to open with `CONTAINER_OFFSET_X = CONTAINER_OFFSET_X or 0`,
-- which is a second written statement of numbers UIParent.xml owns. Harmless in the
-- shipped load order (UIParent.xml is manifest 42, this file later) but it is the same
-- shape as the shadow pair that froze the bag stack at the no-bars corner, so the law
-- of 1499 forbids it outright: nothing outside UIParent.xml assigns a managed offset.
-- The `or` defaults below cover the tooltip-only harnesses that load no UIParent.
local offsetX = CONTAINER_OFFSET_X or 0
local offsetY = CONTAINER_OFFSET_Y or 70
tooltip:SetPoint("BOTTOMRIGHT", "UIParent", "BOTTOMRIGHT",
-offsetX + DEFAULT_TOOLTIP_POSITION, offsetY)
tooltip.default = 1
end
-- The two-line UI-element tooltip (spec: ref-GameTooltip.lua l.107-123): a white label,
-- then the long explanation wrapped underneath in NORMAL_FONT_COLOR — the whole reason a
-- 1.12 hover over the ping meter or a micro button is a paragraph and not four words.
-- With detailed tips on it seats at the DEFAULT corner, not beside the owner; with them off
-- it collapses to the bare label at the owner's right. `noNormalText` says "the explanation
-- is the whole tooltip" (the XP bar) — with tips off such a hover shows nothing at all.
--
-- Signature note: the ref reads the firing frame from the `this` global; the corpus
-- convention is to keep the ref NAME and take that frame as a leading parameter
-- (GameTooltip_OnHide(self), GameTooltip_ClearMoney(frame) — same shift). Reference code
-- pasted in verbatim must therefore have the owner threaded in as arg 1.
-- **The reference's exact signature, because a signature IS api (1199).** Ours used to take
-- the owner as arg 1; the reference takes none and reads `this`. Outfitter calls it the
-- reference's way (`OutfitterMinimapButton:OnEnter` -> `GameTooltip_AddNewbieTip(text, ...)`),
-- so its TEXT landed in our owner slot and `SetOwner` raised
-- "bad argument #2: error converting Lua string to table". Found by the use-probe, not by
-- reading: it only fires when something hovers.
--
-- Our own callers mostly hold the owner in a local that is NOT the firing frame (`bar`,
-- `button`, `frame`), so they use the explicit-owner variant below rather than `this`.
function GameTooltip_AddNewbieTip(normalText, r, g, b, newbieText, noNormalText)
BenillaTooltip_AddNewbieTipFor(this, normalText, r, g, b, newbieText, noNormalText)
end
-- The explicit-owner form our FrameXML uses, and where the behaviour above actually lives:
-- the owner is threaded in rather than read off `this`. Kept under a Benilla name so it can
-- never be mistaken for a reference global an addon might call or replace.
function BenillaTooltip_AddNewbieTipFor(owner, normalText, r, g, b, newbieText, noNormalText)
-- Detailed tips OFF is the short shape: the label alone, beside whatever it labels.
-- A caller that says its label is worthless on its own then has nothing left to show.
if SHOW_NEWBIE_TIPS ~= "1" then
if not noNormalText then
GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
GameTooltip:SetText(normalText, r, g, b)
end
return
end
-- Detailed tips ON: a paragraph, and paragraphs go to the screen corner instead of
-- crowding the thing they explain.
GameTooltip_SetDefaultAnchor(GameTooltip, owner)
if normalText then
-- Label in the caller's colour; the explanation under it, wrapped, always in
-- NORMAL_FONT_COLOR — the two lines are deliberately not the same colour.
GameTooltip:SetText(normalText, r, g, b)
GameTooltip:AddLine(newbieText, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1)
else
-- No label, so the explanation IS the tooltip: it takes the caller's colour, plus
-- SetText's alpha and wrap flags (args 5 and 6) — the wrap the second line above
-- would otherwise have carried.
GameTooltip:SetText(newbieText, r, g, b, 1, 1)
end
GameTooltip:Show()
end
-- ref GameTooltip_OnHide (ref-GameTooltip.lua l.100-104, quoted): the plate colours
-- reset for the next owner and the default-corner flag drops. The ref's OnHide also
-- hides ShoppingTooltip1/2 - that pair lands with the compare-tooltip slice (0274 P4).
--
-- `self or this`: the corpus calls this the REFERENCE way, with no argument at all
-- (TipBuddy.xml l.1412, atsw.xml l.3420, and TipBuddy.lua l.2520 which REPLACES the global
-- and calls the saved original bare). The signature note above explains the corpus-wide
-- shift to a leading parameter; nothing lets us edit an addon, and `this` is exactly what
-- the reference reads here, so the two spellings mean the same thing.
function GameTooltip_OnHide(self)
self = self or this
self:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b)
self:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b)
self.default = nil
end
-- The embedded money row — the ref's SetTooltipMoney (ref-GameTooltip.lua l.84-92) over our
-- shared coin kit (BenillaMoney_Set, MerchantFrame.xml): a blank line, the coin trio seated
-- at the money line's LEFT +4, and the money's width as the tooltip's minimum width. The
-- engine fires OnTooltipAddMoney with the computed price (wow-re ui/scratch/tooltip-money.md
-- 0x52b650@0x52e376: the engine computes, FrameXML renders) and this is its handler; Lua
-- call sites (repair cost) call it directly, exactly like the ref's ContainerFrame does.
function SetTooltipMoney(frame, money)
frame:AddLine(" ", 1.0, 1.0, 1.0)
local numLines = frame:NumLines()
local line = getglobal(frame:GetName() .. "TextLeft" .. numLines)
local coin1 = getglobal(frame:GetName() .. "MoneyCoin1")
coin1:ClearAllPoints()
coin1:SetPoint("LEFT", line, "LEFT", 4, 0)
BenillaMoney_Set(frame:GetName() .. "Money", money, true)
local width = 4
for i = 1, 3 do
local c = getglobal(frame:GetName() .. "MoneyCoin" .. i)
if c:IsShown() then width = width + c:GetWidth() + 4 end
end
frame:SetMinimumWidth(width)
end
-- The money row clears whenever the content does (the ref wires GameTooltip_ClearMoney to
-- OnTooltipCleared; the engine fires it on ClearLines/SetOwner/Hide). BenillaMoney_Clear
-- ships in MerchantFrame.xml (the money kit's home) — guard for engine tests that load
-- this file alone; in the app every file is loaded.
function GameTooltip_ClearMoney(frame)
frame = frame or this
if BenillaMoney_Clear then
BenillaMoney_Clear(frame:GetName() .. "Money")
end
end
-- The item content channels (SetItemById/SetBagItem/SetMerchantItem/SetBuybackItem) are
-- ENGINE methods on the GameTooltip widget (benilla-ui script/tooltip_item.rs, decision
-- 0274 P1): one shared renderer — the real client's own shape (0x52b650 behind 8 of the
-- 9 Set*Item bindings, wow-re ui/scratch/tooltip-money.md; decision 0126's one-renderer
-- law) — over the full item-template store, with the red failed-requirement law and the
-- engine-fired sell-price money row (merchant open + real instance + repair off).
-- The reaction color table (ref ReputationFrame.lua l.3-12, quoted) — the unit-name
-- recolor's palette; guarded so a later reputation window's own definition wins.
FACTION_BAR_COLORS = FACTION_BAR_COLORS or {
[1] = { r = 0.8, g = 0.3, b = 0.22 },
[2] = { r = 0.8, g = 0.3, b = 0.22 },
[3] = { r = 0.75, g = 0.27, b = 0 },
[4] = { r = 0.9, g = 0.7, b = 0 },
[5] = { r = 0, g = 0.6, b = 0.1 },
[6] = { r = 0, g = 0.6, b = 0.1 },
[7] = { r = 0, g = 0.6, b = 0.1 },
[8] = { r = 0, g = 0.6, b = 0.1 },
}
-- GameTooltip_UnitColor (ref-GameTooltip.lua l.8-71): creatures color by reaction;
-- players read white (the ref's own shipping state — its hostile/PvP player branches
-- are the FACTION_BAR_COLORS reds/greens, kept for when PvP states stream). The engine
-- prints the name gold and fires UPDATE_MOUSEOVER_UNIT; this recolors line 1 (the
-- byte-verified split — the name COLOR is FrameXML's, decision 0276).
function GameTooltip_UnitColor(unit)
local reaction = UnitReaction(unit, "player")
if reaction and FACTION_BAR_COLORS[reaction] then
local c = FACTION_BAR_COLORS[reaction]
return c.r, c.g, c.b
end
return 1.0, 1.0, 1.0
end
-- The health bar's own fill colour (spec: ref-HealthBar.lua, the whole file).
--
-- Two colourings in one handler. `smooth` asks for the health RAMP — green at the top of
-- the range, red at the bottom, and between them a two-segment fade that pins one channel
-- at full and slides the other, so the halfway mark reads solid yellow. Without it the bar
-- is flat green whatever it reads. Blue is 0 either way, which is what keeps the fade
-- inside the red-yellow-green wedge and never lets it wash out.
--
-- **Nothing passes `smooth` — not us and not the reference.** Verified: our only caller is
-- the status bar's `<OnValueChanged>` below (one argument), and 1.12's own two callers are
-- ref-GameTooltipTemplate.xml l.593 and ref-UnitFrame.lua l.181, both of which forward
-- `value` alone. So the ramp has never coloured a bar in this game; it is kept because it
-- is the contract an addon replacing a unit frame may still call with two arguments.
--
-- A value outside the bar's own range leaves the colour untouched rather than being clamped
-- to an end of the ramp — the reference's own guard, kept as the reference has it.
function HealthBar_OnValueChanged(value, smooth)
if not value then return end
local bar = this
local min, max = bar:GetMinMaxValues()
if value < min or value > max then return end
local r, g = 0.0, 1.0
if smooth then
local filled = 0
if max > min then filled = (value - min) / (max - min) end
if filled > 0.5 then
r, g = (1.0 - filled) * 2, 1.0
else
r, g = 1.0, filled * 2
end
end
bar:SetStatusBarColor(r, g, 0.0)
end
-- `self or this` for the same reason as GameTooltip_OnHide: an addon inheriting
-- GameTooltipTemplate re-declares <OnLoad> to add its own line and opens it with the
-- reference's bare `GameTooltip_OnLoad();` (TipBuddy.xml l.1402, atsw.xml l.3417) — a nil
-- self there is a load ERROR, and the plate it was about to tint never gets its colours.
-- The body is the ref's two lines and NOTHING else. It used to end with a `self:Hide()` we
-- had added, which the reference (l.79-82, the whole function) does not have — inert for
-- our four tooltips, since every one of them carries `hidden="true"` and the loader's
-- attribute pass runs Hide() long before OnLoad fires, but NOT inert for an addon: two of
-- the 27 declare `hidden="false"` on purpose (PowerAuras.xml l.20, SimpleActionSets.xml
-- l.349) and never call Show(), so the extra line hid a tooltip the real client leaves up.
-- A helper that quietly does more than the reference's is exactly the kind of thing that
-- only shows up when someone else's code calls it.
function GameTooltip_OnLoad(self)
self = self or this
-- The plate colours (ref GameTooltip_OnLoad, ref-GameTooltip.lua l.79-82).
self:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b)
self:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b)
end
]]></Script>
<!--
GameTooltipTemplate (ref-GameTooltipTemplate.xml l.3-627) — the shared tooltip SHAPE, and
the single most-wanted template in the addon corpus: 27 addons name it in an XML
`inherits=` (TipBuddy, AtlasLoot, CT_BarMod, MikScrollingBattleText, TheoryCraft, …) and
two more reach it through CreateFrame("GameTooltip", name, nil, "GameTooltipTemplate").
Every one of those declarations is a <GameTooltip> element, so the template's job is to
hand them the PLATE and the STATUS BAR; the line pairs they then read
(`getglobal(tip:GetName().."TextLeft1")`) are engine-created per instance and can never
carry the template's name.
What is here is what the reference's template carries and all our instances shared, moved
verbatim from the GameTooltip instance below: the backdrop plate, the Thicken deviation
(its rationale note travels WITH it — this is the shared plate, not one window's), the
$parentStatusBar, and the OnLoad/OnHide pair (ref l.611-616).
What is deliberately NOT here, and stays on the GameTooltip instance:
- The money coin trio and the OnTooltipAddMoney/OnTooltipCleared pair. The reference's
template carries a $parentMoneyFrame (l.598-608, a SmallMoneyFrameTemplate), so its
handler is safe on every instance; ours is a bespoke three-coin row that only
GameTooltip declares, so a templated OnTooltipAddMoney would index a MoneyCoin1 that
does not exist. Moving the row up would give ItemRefTooltip and every addon tooltip a
coin line they render today without — a visible change, so it waits for the director.
- OnTooltipSetDefaultAnchor (ref l.617-619). The engine fires it by name on `GameTooltip`
alone (script/tooltip_unit.rs looks the frame up), so on any other instance it is dead
weight.
`clampedToScreen="true"` is the reference's own attribute and is a no-op for us: decision
0352 made the flag default-ON for every GameTooltip-kind frame, because the reference plate
observably clamps though its XML never sets it. Kept because it is what the reference says.
-->
<GameTooltip name="GameTooltipTemplate" frameStrata="TOOLTIP" clampedToScreen="true" hidden="true" virtual="true">
<!-- The tooltip plate (ref-GameTooltipTemplate.xml l.4-14 / ref-GameTooltip.xml l.33-43):
UI-Tooltip-Background tiled, UI-Tooltip-Border edge, EdgeSize 16, TileSize 16, insets
5/5/5/5. Colours are set in OnLoad (SetBackdropColor/BorderColor) — no <Color> here, so
the plate starts ctor-white and OnLoad tints it. -->
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<EdgeSize><AbsValue val="16"/></EdgeSize>
<TileSize><AbsValue val="16"/></TileSize>
<BackgroundInsets><AbsInset left="5" right="5" top="5" bottom="5"/></BackgroundInsets>
</Backdrop>
<Layers>
<Layer level="BACKGROUND">
<!-- DELIBERATE deviation (director's call): the real plate is UI-Tooltip-Background's
baked 187/255 alpha (~73% opaque — measured from the BLP; the tint can only
lower it) and read too see-through here. This solid fill in the same navy,
inset to the backdrop's 5px background insets, thickens the plate to ~84%
against the world. The `a` is the knob; delete the region for the raw quote. -->
<Texture name="$parentThicken">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="5" y="-5"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-5" y="5"/></Offset></Anchor>
</Anchors>
<Color r="0.09" g="0.09" b="0.19" a="0.4"/>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- **The 30 pre-declared line pairs, exactly as ref-GameTooltipTemplate.xml l.17-627.**
They exist so `GameTooltipTextLeft1` is a REAL GLOBAL FROM LOAD, hidden, before any
line has been added. Ours were engine-created purely on demand, so cold they were
nil — and the corpus pattern that breaks on is a GUARD, not a read:
`Participant/Resurrection.lua:294` and `CT_RaidAssist/CT_RADetectSpells.lua:47` both
say `GameTooltipTextLeft1:IsVisible()` to ask whether the tooltip is showing
anything. On the reference that is safe and answers false; for us it raised on the
check itself. 7 corpus addons index the name (decision 1220).
The engine ADOPTS these (`script::tooltip::ensure_lines` — the same path
ShoppingTooltipTemplate's ladder already uses): their faces ride, the engine owns
justify, visibility and anchors, so no anchors are declared here. Faces match what
`ensure_lines` picks for a created line — header for pair 1, GameTooltipText after —
which is why this changes nothing on screen. `NumLines()` is `num_lines`, not the
pool size, and `layout_tooltips` sizes from `num_lines`, so a declared-but-unfilled
pair adds no height and no width. -->
<FontString name="$parentTextLeft1" inherits="GameTooltipHeaderText" hidden="true"/>
<FontString name="$parentTextRight1" inherits="GameTooltipHeaderText" hidden="true"/>
<FontString name="$parentTextLeft2" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight2" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft3" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight3" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft4" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight4" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft5" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight5" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft6" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight6" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft7" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight7" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft8" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight8" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft9" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight9" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft10" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight10" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft11" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight11" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft12" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight12" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft13" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight13" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft14" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight14" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft15" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight15" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft16" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight16" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft17" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight17" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft18" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight18" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft19" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight19" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft20" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight20" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft21" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight21" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft22" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight22" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft23" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight23" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft24" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight24" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft25" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight25" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft26" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight26" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft27" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight27" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft28" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight28" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft29" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight29" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft30" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight30" inherits="GameTooltipText" hidden="true"/>
</Layer>
</Layers>
<Frames>
<!-- The mouseover health bar (ref-GameTooltipTemplate.xml l.575-597, quoted): 8px
tall, hanging below the plate, UI-TargetingFrame-BarFill, colored by
HealthBar_OnValueChanged. The ENGINE drives min/max/value from the live unit
snapshot (the byte law's HEALTH watcher — SetUnit + every set_unit push), finding
it by the `<name>StatusBar` global — so it has to be the TEMPLATE's, not one
instance's: TipBuddy replaces the whole unit tooltip and both `parent=` and
`relativeTo=` a `TipBuddyTooltipStatusBar` it never declares
(TipBuddy.xml l.2320, l.2591) — a name only this element can publish. -->
<StatusBar name="$parentStatusBar" hidden="true">
<Size><AbsDimension x="0" y="8"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-2" y="-1"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnValueChanged>HealthBar_OnValueChanged(arg1)</OnValueChanged>
</Scripts>
<BarTexture name="$parentTexture" file="Interface\TargetingFrame\UI-TargetingFrame-BarFill"/>
</StatusBar>
</Frames>
<Scripts>
<!-- ref-GameTooltipTemplate.xml l.611-616. An instance that needs more re-declares the
handler and calls the same helper first (the reference's own shape, and ours:
GameTooltip adds its event registration, ItemRefTooltip its padding) — the loader
walks every <Scripts> block in splice order, so the instance's SetScript lands
last and wins. -->
<OnLoad>GameTooltip_OnLoad(self)</OnLoad>
<OnHide>GameTooltip_OnHide(this)</OnHide>
</Scripts>
</GameTooltip>
<GameTooltip name="GameTooltip" inherits="GameTooltipTemplate" parent="UIParent" frameStrata="TOOLTIP" hidden="true">
<!-- A default size so the plate resolves the first frame it is shown; the engine's
auto-size pass takes over once the lines are measured (one frame's latency). NO
load-time anchor (the ref instance carries none): every show path seats the plate
first - SetOwner's anchor law for owned hovers, OnTooltipSetDefaultAnchor for world
hovers - and a leftover static anchor is exactly what parked the world tooltip at
screen center when the default-anchor script wasn't wired. -->
<Size><AbsDimension x="120" y="32"/></Size>
<Frames>
<!-- The money row's coin trio (the real $parentMoneyFrame — GameTooltipTemplate.xml,
patch.MPQ — as our shared coin-slot shape; MerchantFrame.xml's BenillaMoney_Set
fills them, left-to-right gold-first). Coin1 is re-anchored by SetTooltipMoney to
the money line's LEFT +4 (ref SetTooltipMoney's seat). -->
<Frame name="$parentMoneyCoin1" hidden="true">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.5" right="0.75" top="0" bottom="1"/>
</Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT">
<Size><AbsDimension x="20" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="$parentMoneyCoin2" hidden="true">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentMoneyCoin1" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.25" right="0.5" top="0" bottom="1"/>
</Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT">
<Size><AbsDimension x="20" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="$parentMoneyCoin3" hidden="true">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentMoneyCoin2" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="1"/>
</Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT">
<Size><AbsDimension x="20" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
</Frames>
<Scripts>
<OnLoad>
GameTooltip_OnLoad(self)
self:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
</OnLoad>
<!-- The engine renders the mouseover unit's lines gold and fires this; the recolor by
reaction is FrameXML's half (ref-GameTooltip.xml l.12-16, quoted). -->
<OnEvent>
if event == "UPDATE_MOUSEOVER_UNIT" then
getglobal(this:GetName() .. "TextLeft1"):SetTextColor(GameTooltip_UnitColor("mouseover"))
end
</OnEvent>
<!-- The default corner for world hovers (ref-GameTooltipTemplate.xml l.617-619,
quoted): the engine fires this before rendering a mouseover unit/object, and
the handler seats the plate at the screen's bottom-right. THE wiring whose
absence parked the world tooltip on the character. -->
<OnTooltipSetDefaultAnchor>GameTooltip_SetDefaultAnchor(this, UIParent)</OnTooltipSetDefaultAnchor>
<!-- ref GameTooltip.xml OnHide (l.18-22, quoted): the color reset + the compare pair
drops with its anchor host. -->
<OnHide>
GameTooltip_OnHide(this)
ShoppingTooltip1:Hide()
ShoppingTooltip2:Hide()
</OnHide>
<!-- The engine-fired money protocol (ref-GameTooltipTemplate.xml l.620-625): the engine
computes a price and fires OnTooltipAddMoney(arg1 = copper); OnTooltipCleared fires
whenever the content clears (ClearLines/SetOwner/Hide) and drops the coin row. -->
<OnTooltipAddMoney>SetTooltipMoney(this, arg1)</OnTooltipAddMoney>
<OnTooltipCleared>GameTooltip_ClearMoney(this)</OnTooltipCleared>
</Scripts>
</GameTooltip>
<!--
ShoppingTooltipTemplate (ref-GameTooltipTemplate.xml l.628-1217) — the shopping-compare
pair's own template, a SEPARATE virtual from GameTooltipTemplate exactly as the reference
has it (its instances, ref-GameTooltip.xml l.24-25, inherit this and not the main one).
It repeats the plate rather than inheriting it because the reference's does, and because
inheriting GameTooltipTemplate would hand the compare plates its OnHide, its status bar and
(later) its money row — none of which the compare cut has.
The declared TextLeft/Right1-4 pairs are this template's own small-font ladder, faces
quoted verbatim (including its odd GameFontNormal cells) — the engine ADOPTS declared pairs
and clones the last pair's faces for lines it grows past them (tooltip.rs ensure_lines).
Same plate as the main tooltip, including the Thicken deviation (its rationale is on
GameTooltipTemplate above); no coin trio — the compare cut ends before money.
No addon in the corpus names this template (0 of 218, against GameTooltipTemplate's 27), so
it earns its keep as OUR de-duplication, not as an addon surface: the two instances below
were byte-identical apart from the name.
-->
<GameTooltip name="ShoppingTooltipTemplate" frameStrata="TOOLTIP" hidden="true" virtual="true">
<Size><AbsDimension x="120" y="32"/></Size>
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<EdgeSize><AbsValue val="16"/></EdgeSize>
<TileSize><AbsValue val="16"/></TileSize>
<BackgroundInsets><AbsInset left="5" right="5" top="5" bottom="5"/></BackgroundInsets>
</Backdrop>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentThicken">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="5" y="-5"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-5" y="5"/></Offset></Anchor>
</Anchors>
<Color r="0.09" g="0.09" b="0.19" a="0.4"/>
</Texture>
</Layer>
<Layer level="ARTWORK">
<FontString name="$parentTextLeft1" inherits="GameFontNormalSmall" hidden="true"/>
<FontString name="$parentTextRight1" inherits="GameFontNormal" hidden="true"/>
<FontString name="$parentTextLeft2" inherits="GameFontNormal" hidden="true"/>
<FontString name="$parentTextRight2" inherits="GameFontHighlightSmall" hidden="true"/>
<FontString name="$parentTextLeft3" inherits="GameFontHighlightSmall" hidden="true"/>
<FontString name="$parentTextRight3" inherits="GameFontHighlightSmall" hidden="true"/>
<FontString name="$parentTextLeft4" inherits="GameTooltipTextSmall" hidden="true"/>
<FontString name="$parentTextRight4" inherits="GameFontHighlightSmall" hidden="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>GameTooltip_OnLoad(self)</OnLoad>
</Scripts>
</GameTooltip>
<!-- The shopping-compare pair (ref GameTooltip.xml l.24-25, quoted down to the one-line shape):
two more GameTooltip widgets the engine seats on the paperdoll slot when
SHOW_COMPARE_TOOLTIP fires (CharacterFrame.xml's listener). -->
<GameTooltip name="ShoppingTooltip1" inherits="ShoppingTooltipTemplate" parent="UIParent" frameStrata="TOOLTIP" hidden="true"/>
<GameTooltip name="ShoppingTooltip2" inherits="ShoppingTooltipTemplate" parent="UIParent" frameStrata="TOOLTIP" hidden="true"/>
</Ui>
File diff suppressed because it is too large Load Diff
@@ -152,7 +152,7 @@
-- ── Key display ── the 1.12 GetBindingText law by hand: modifier prefixes stay the
-- canonical uppercase tokens, the base key reads its KEY_* global (Spacebar, Up Arrow,
-- Mouse Button 4...) else itself. "CTRL--" renders CTRL + the minus key. (The action
-- bar's ABBREVIATED labels ride our own GetBindingText in UIParent.xml;
-- bar's ABBREVIATED labels ride the stock GetBindingText (UIParent.lua);
-- this page and the tooltips show the full form, both references' own split.)
function KeyBindings_KeyText(key)
if not key then
@@ -184,7 +184,7 @@
-- and repainted after a bind lands on one.
KeyBindingsPage = { sections = {}, items = {} }
KEYBINDINGS_ROW_BASE = "OptionsFrameContainerBodyKeybindingsRow"
KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
KEYBINDINGS_DISPLAYED = 19
KEYBINDINGS_ROW_HEIGHT = 25
KEYBINDINGS_SEARCH_ROWS = 8
@@ -302,7 +302,7 @@
-- ── The list repaint (1.12 KeyBindingFrame_Update over the era section list). ──
function KeyBindingsPage_Update()
local page = KeyBindingsPage
local scroll = getglobal("OptionsFrameContainerBodyKeybindingsScrollFrame")
local scroll = getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrame")
local offset = FauxScrollFrame_GetOffset(scroll)
for i = 1, KEYBINDINGS_DISPLAYED do
local name = KEYBINDINGS_ROW_BASE .. i
@@ -327,23 +327,23 @@
KeyBindings_UpdateUnbindKey()
end
-- The page entry (OptionsFrame_SelectCategory's Keybindings arm): rebuild the tree —
-- The page entry (BenillaOptionsFrame_SelectCategory's Keybindings arm): rebuild the tree —
-- the table may have moved under us (a rebind, a set switch) — sync the checkbox to
-- the live set, park the scroll at the top, repaint.
function KeyBindingsPage_Refresh()
KeyBindings_BuildSections()
local box = getglobal("OptionsFrameContainerBodyKeybindingsCharacterRowCheck")
local box = getglobal("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck")
if GetCurrentBindingSet() == 2 then
box:SetChecked(1)
else
box:SetChecked(nil)
end
getglobal("OptionsFrameContainerBodyKeybindingsScrollFrameScrollBar"):SetValue(0)
getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrameScrollBar"):SetValue(0)
KeyBindingsPage_Update()
end
function KeyBindings_Output(text)
getglobal("OptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
getglobal("BenillaOptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
end
-- ── Selection + the capture arm ── selecting a capsule arms the HOST's capture seam
@@ -356,7 +356,7 @@
end
function KeyBindings_UpdateUnbindKey()
local unbind = getglobal("OptionsFrameContainerUnbind")
local unbind = getglobal("BenillaOptionsFrameContainerUnbind")
if KeyBindingsPage.selected then
unbind:Enable()
else
File diff suppressed because it is too large Load Diff
-416
View File
@@ -1,416 +0,0 @@
<Ui>
<!--
The MICRO MENU — the row of eight small arched buttons in the main bar's right-hand recess
(character, spellbook, talents, quest log, socials, world map, main menu, help). It was the
last hole in the bar: ActionBar.xml's SCOPE note named "the bag/keyring/micro cluster on the
bar's right" as the visible remaining gap, and with the bag bar landed (decision 0216 slice
2) the micro row was the empty dark panel between the page arrows and the bags.
Transcribed from the real 1.12 FrameXML — ref-MainMenuBarMicroButtons.xml (the template +
the eight buttons' geometry) and ref-MainMenuBarMicroButtons.lua (LoadMicroButtonTextures,
UpdateMicroButtons, the CharacterMicroButton portrait, UpdateTalentButton), both extracted
from patch.MPQ. Geometry QUOTED: each button 29x58 with `HitRectInsets top=18`; the first at
MainMenuBarArtFrame BOTTOMLEFT +(552,2); the rest chained BOTTOMLEFT to the previous
BOTTOMRIGHT +(-3,0) — a 26 px stride, so the row runs x 552..757 inside the 1024-wide bar.
NAMES are the reference's, not Benilla*-prefixed (unlike the multibars/stance bar, which are
benilla-shaped divergences). These are straight transcriptions, and `UpdateMicroButtons` is
the ref global that CharacterFrame/SpellBookFrame/TalentFrame already cite by name in their
own OnShow/OnHide comments — those calls are now live instead of OMITTED, in all five
panels the ref calls it from (+ QuestLogFrame, WorldMapFrame). They are nil-GUARDED at the
call sites (`if UpdateMicroButtons then … end`) — not because the load order is in doubt
(this file always precedes them), but because a dozen test harnesses load one panel without
the action bar behind it, and an unguarded call would abort the rest of that OnShow. Same
posture as UiPanels.xml's `CloseMenus and CloseMenus()` and the `if GameTooltip` guards.
Structure: the buttons are declared at this file's top level and carry
`parent="MainMenuBarArtFrame"`, which really does attach them to the bar art — the loader
reads a cross-file `parent=`, resolving the name by global lookup at load (decision 1734),
so the art must already exist when this file loads, which the manifest order gives it. They
are anchored into it by name as well, and seated one frame level above it by
BenillaActionBarArt_SeatAbove (ActionBar.xml) so the bar plate doesn't paint over them —
both kept as they are, because that is the row the bar was built and approved against. Same
shape the bag bar next door already uses.
SCOPE / real-client divergences (noted, not bugs):
- EVERY button now opens something. Help was the last "art only, wired the day the panel
lands" one, and HelpFrame.xml is that panel — its click is `ToggleHelpFrame()` and its
pushed arm is in UpdateMicroButtons below, exactly as the ref writes both. (Socials went
live with decision 0668, Main Menu with 0674, Help with the HelpFrame transcription.)
The bar was deliberately never greyed with the ref's `-Disabled` art while the gap
lasted: a dead grey button mid-row reads as a broken bar, and it really was one OnClick
line from live.
- The "(key)" suffix is LIVE since 0997: `button.binding` carries the binding ACTION
name (TOGGLECHARACTER0, TOGGLESPELLBOOK, ...) and the tooltip looks the key up through
GetBindingKey at hover time — the ref MicroButtonTooltipText's own contract, restored
now that the binding table exists. A genuinely unbound action shows the bare label.
- The hover is the ref's real two-line GameTooltip_AddNewbieTip (GameTooltip.xml): the label,
then the button's NEWBIE_TOOLTIP_* explanation, at the default screen corner. An earlier
note here claimed detailed tips were OFF in 1.12 and open-coded the else branch — the ref
ships them ON (UIOptionsFrame.lua l.100), so that was a one-line tooltip where the real
client shows a paragraph. Corrected by decision 0661.
- TalentMicroButton registers UNIT_LEVEL + PLAYER_ENTERING_WORLD but NOT the ref's
PLAYER_LEVEL_UP (this client never fires it) — and PLAYER_LEVEL_UP's only other job there
is SetButtonPulse, which decision 0304 already scoped out ("no microbutton pulse").
- UpdateTalentButton's re-anchor uses -3 both ways; the ref writes -3 in the XML but -2 in
the Lua, so a real client nudges the whole row 1 px left the first time it re-anchors at
level 10. That's a ref typo, not a mechanism — not reproduced.
- SetButtonState is called without the ref's second "lock" argument (this engine has no
state lock; the pushed texture already draws from pushed-state OR a live mouse hold, so
the rendered result is identical for a button nothing else pushes).
- The keyring button (ref KeyRingButton, `hidden="true"` by default) stays out with the
keyring container itself.
-->
<!-- ── MainMenuBarMicroButton (ref MainMenuBarMicroButtons.xml l.4-18, VERBATIM) ─────────────
The micro-button base template. Ours declares its buttons directly, so nothing here needed
it — but an ADDON that adds a micro button inherits this name, and an unresolved
`inherits=` is silent: no error, just a button with no size, no hit-rect inset and no
tooltip scripts.
Verbatim, and it costs nothing: `GameTooltip_AddNewbieTip` is already ours (GameTooltip.xml)
and the rest is geometry. The HitRectInsets top=18 is the reference's own — the micro
buttons overlap the bar art above them, and without it the top third of every addon micro
button would eat clicks meant for the bar. -->
<Button name="MainMenuBarMicroButton" virtual="true">
<Size><AbsDimension x="29" y="58"/></Size>
<HitRectInsets><AbsInset left="0" right="0" top="18" bottom="0"/></HitRectInsets>
<Scripts>
<OnEnter>
GameTooltip_AddNewbieTip(this.tooltipText, 1.0, 1.0, 1.0, this.newbieText);
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Button>
<Script><![CDATA[
-- ref GlobalStrings.lua — the eight labels (enUS), in the `X = X or` FALLBACK form: the
-- app runs the real localized GlobalStrings.lua off the patch chain BEFORE this file
-- (ui_script/mod.rs load_global_strings), so the installed locale wins and these copies
-- only fill in for the test harnesses, which load no patch chain. CombatText.xml's shape.
CHARACTER_BUTTON = CHARACTER_BUTTON or "Character Info"
SPELLBOOK_ABILITIES_BUTTON = SPELLBOOK_ABILITIES_BUTTON or "Spellbook & Abilities"
TALENTS_BUTTON = TALENTS_BUTTON or "Talents"
QUESTLOG_BUTTON = QUESTLOG_BUTTON or "Quest Log"
SOCIAL_BUTTON = SOCIAL_BUTTON or "Social"
WORLDMAP_BUTTON = WORLDMAP_BUTTON or "World Map"
MAINMENU_BUTTON = MAINMENU_BUTTON or "Main Menu"
HELP_BUTTON = HELP_BUTTON or "Help Request"
-- ...and their second lines, ref GlobalStrings.lua l.2722/2766/2768/2758/2765/2788/2747/2739,
-- verbatim (enUS). 1.12 ships detailed tooltips ON (decision 0661), so every one of these is
-- part of the default hover, not an opt-in extra.
NEWBIE_TOOLTIP_CHARACTER = NEWBIE_TOOLTIP_CHARACTER or "Information about your character, including equipment, statistics, skills, and reputation."
NEWBIE_TOOLTIP_SPELLBOOK = NEWBIE_TOOLTIP_SPELLBOOK or "All of your spells and abilities. To move a spell or ability to your Action Bar, open the Spellbook & Abilities window, left-click that spell or ability, and drag it down to your Action Bar."
NEWBIE_TOOLTIP_TALENTS = NEWBIE_TOOLTIP_TALENTS or "The array of talents available to enhance and specialize your character."
NEWBIE_TOOLTIP_QUESTLOG = NEWBIE_TOOLTIP_QUESTLOG or "A list of all the active quests you currently have. You can have up to 20 active quests at one time."
NEWBIE_TOOLTIP_SOCIAL = NEWBIE_TOOLTIP_SOCIAL or "Information about other people in the game. You can use the Social window to manage your friends list and ignore list, as well as see who is online."
NEWBIE_TOOLTIP_WORLDMAP = NEWBIE_TOOLTIP_WORLDMAP or "As you explore a zone, more areas of interest will become visible on the world map. You can use left-click to zoom in, or right-click to zoom out."
NEWBIE_TOOLTIP_MAINMENU = NEWBIE_TOOLTIP_MAINMENU or "Here you can modify your video, sound, and interface settings, or create custom hotkeys. You can also choose to log out or exit the program altogether."
NEWBIE_TOOLTIP_HELP = NEWBIE_TOOLTIP_HELP or "Through this menu, you can report a bug. You can also use the Help Request menu to talk to one of our Game Masters (GMs) in the game if you have questions or need assistance."
-- ref LoadMicroButtonTextures (MainMenuBarMicroButtons.lua l.1-9): the Up/Down/Disabled trio
-- under one art name plus the shared highlight. `art` is nil for CharacterMicroButton, whose
-- textures don't follow the pattern (UI-MicroButtonCharacter-*, and no Disabled variant
-- exists in the archives) — it sets its own.
-- `newbie` is the ref's per-button `this.newbieText` (set in each button's own OnLoad there —
-- MainMenuBarMicroButtons.lua l.95 and .xml l.101/127/149/174/193/215); Spellbook is the one
-- the ref hands to the call site instead, because its label swaps with GetSpellBookState.
function BenillaMicroButton_OnLoad(button, art, label, binding, newbie)
button.label = label
button.binding = binding
button.newbieText = newbie
-- Seat over the bar plate (ActionBar.xml's helper, which carries the why). Called
-- nil-guarded for the same reason BagFrame.xml's two calls are: the shipped load order
-- always has the bar first, but a test harness may load this file alone — and with no
-- bar there is nothing to seat above.
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(button) end
if art then
local prefix = "Interface\\Buttons\\UI-MicroButton-" .. art
button:SetNormalTexture(prefix .. "-Up")
button:SetPushedTexture(prefix .. "-Down")
button:SetDisabledTexture(prefix .. "-Disabled")
end
button:SetHighlightTexture("Interface\\Buttons\\UI-MicroButton-Hilight")
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end
-- ref MicroButtonTooltipText (MainMenuBarMicroButtons.lua l.11-18), VERBATIM — the
-- reference's own two-argument form: a label and a binding ACTION name, returning the
-- label with the bound key in NORMAL_FONT_COLOR parentheses, or the bare label when the
-- action is unbound.
--
-- It lives here because this file is our `MainMenuBarMicroButtons.xml`, which is where the
-- reference declares it. It is not the micro bar's own helper: the stock `CharacterFrame`
-- tab row calls it at all five `<OnEnter>` sites
-- (`MicroButtonTooltipText(TEXT(CHARACTER_INFO), "TOGGLECHARACTER0")` and its four
-- siblings), so migrating that window onto the chain (decision 1751) needs this name to
-- answer or every character-tab hover raises. `SpellBookFrame.xml` already guards on it
-- with `if MicroButtonTooltipText then`; that guard is now always true.
--
-- Ours below is the BUTTON-shaped variant, kept because our own micro buttons carry the
-- action in `button.binding` and the label in `button.label`, and because it renders the
-- key through the Key Bindings window's display law. The reference's version prints
-- `GetBindingKey`'s raw token, which is what the real client shows, so the two are not
-- folded together: this one is the reference's text, the other is ours.
function MicroButtonTooltipText(text, action)
if GetBindingKey(action) then
return text .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. GetBindingKey(action) .. ")"
.. FONT_COLOR_CODE_CLOSE
else
return text
end
end
-- ref MicroButtonTooltipText (l.11-18), the LIVE contract since 0997: `button.binding`
-- is the binding ACTION name and the key resolves through GetBindingKey at hover time
-- (rendered through the Key Bindings window's display law when it is loaded — the
-- shipped order always has it; a lone-file harness shows the raw token).
function BenillaMicroButton_TooltipText(button)
if button.binding then
local key = GetBindingKey(button.binding)
if key then
if KeyBindings_KeyText then key = KeyBindings_KeyText(key) end
return button.label .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. key .. ")" .. FONT_COLOR_CODE_CLOSE
end
end
return button.label
end
-- ref MainMenuBarMicroButton's OnEnter (l.13), verbatim bar the owner shift:
-- BenillaTooltip_AddNewbieTipFor(this.tooltipText, 1.0, 1.0, 1.0, this.newbieText)
function BenillaMicroButton_OnEnter(button)
if not GameTooltip_AddNewbieTip then return end -- engine tests load file subsets
BenillaTooltip_AddNewbieTipFor(button, BenillaMicroButton_TooltipText(button), 1.0, 1.0, 1.0,
button.newbieText)
end
-- Is a panel up? `getglobal` because every panel this asks about loads AFTER this file —
-- the first UpdateMicroButtons can land before most of them exist.
function BenillaMicro_PanelShown(name)
local frame = getglobal(name)
return frame ~= nil and frame:IsVisible()
end
function BenillaMicroButton_SetPushed(button, isPushed)
if not button then return end
if isPushed then
button:SetButtonState("PUSHED")
else
button:SetButtonState("NORMAL")
end
end
-- ref UpdateMicroButtons (l.20-84): each button reflects whether its panel is up. The
-- MainMenu arm is the ref's own six-way test (l.47-58) minus the five frames that don't
-- exist here — GameMenuFrame is the one that does (decision 0674), and it is the one the
-- reference lists first. Its keyring arm (l.79-83) still has no frame here.
--
-- The Help arm is the ref's l.72-76, live since HelpFrame.xml landed. It calls BACK into
-- this function from its own OnShow and OnHide, which is the ref's wiring too and is why
-- the reference's HelpFrame is the one panel that does not need an event to stay in sync.
function UpdateMicroButtons()
BenillaMicroButton_SetPushed(CharacterMicroButton, BenillaMicro_PanelShown("CharacterFrame"))
BenillaMicroButton_SetPushed(SpellbookMicroButton, BenillaMicro_PanelShown("SpellBookFrame"))
BenillaMicroButton_SetPushed(TalentMicroButton, BenillaMicro_PanelShown("TalentFrame"))
BenillaMicroButton_SetPushed(QuestLogMicroButton, BenillaMicro_PanelShown("QuestLogFrame"))
-- ref l.60-64. Missing since the Socials button went live with decision 0668: the
-- window opened but its button never read pushed. Spotted while wiring Help, which is
-- the same arm one line down; fixed here rather than left for a bug report.
BenillaMicroButton_SetPushed(SocialsMicroButton, BenillaMicro_PanelShown("FriendsFrame"))
BenillaMicroButton_SetPushed(WorldMapMicroButton, BenillaMicro_PanelShown("WorldMapFrame"))
BenillaMicroButton_SetPushed(MainMenuMicroButton, BenillaMicro_PanelShown("GameMenuFrame"))
BenillaMicroButton_SetPushed(HelpMicroButton, BenillaMicro_PanelShown("HelpFrame"))
end
-- ref CharacterMicroButton_SetPushed/_SetNormal (l.109-117): the portrait shifts its window
-- and dims while the button is down. The window is a TexCoord crop of the SAME "player"
-- portrait bake the unit frame samples (SetPortraitTexture below) — the round stencil lives
-- in the bake's own UV space, so cropping it yields the ref's rectangular slice of a masked
-- face rather than an ellipse fitted to this 18x25 region.
function CharacterMicroButton_SetPushed()
MicroButtonPortrait:SetTexCoord(0.2666, 0.8666, 0, 0.8333)
MicroButtonPortrait:SetAlpha(0.5)
end
function CharacterMicroButton_SetNormal()
MicroButtonPortrait:SetTexCoord(0.2, 0.8, 0.0666, 0.9)
MicroButtonPortrait:SetAlpha(1.0)
end
-- ref CharacterMicroButton's OnUpdate (its XML l.55-61) drives the portrait off the live
-- button state — which is what makes the face dim mid-mouse-hold, a thing UpdateMicroButtons
-- can't see. Transcribed, but EDGE-TRIGGERED: the ref re-applies both setters every frame;
-- there is no reason to re-push identical TexCoord/alpha 60x a second.
function CharacterMicroButton_OnUpdate(button)
local pushed = (button:GetButtonState() == "PUSHED")
if pushed ~= button.portraitPushed then
button.portraitPushed = pushed
if pushed then
CharacterMicroButton_SetPushed()
else
CharacterMicroButton_SetNormal()
end
end
end
function CharacterMicroButton_OnLoad(button)
-- The player's face, from the same portrait slot the unit frame binds (portrait/mod.rs
-- keeps the "player" entry live across re-bakes, so unlike the ref there is no
-- UNIT_PORTRAIT_UPDATE re-bind to register — the binding is by token, not by handle).
SetPortraitTexture(MicroButtonPortrait, "player")
button:SetNormalTexture("Interface\\Buttons\\UI-MicroButtonCharacter-Up")
button:SetPushedTexture("Interface\\Buttons\\UI-MicroButtonCharacter-Down")
BenillaMicroButton_OnLoad(button, nil, CHARACTER_BUTTON, "TOGGLECHARACTER0", NEWBIE_TOOLTIP_CHARACTER)
end
-- ref UpdateTalentButton (l.133-141): no talents before level 10, so the button goes away
-- and the row closes up behind it. Guarded on BOTH frames: it re-anchors the quest-log
-- button, which is created after the talent one, so the very first call has to be able to
-- land before the row is fully built. (`UnitLevel` answers 0, never nil, for a unit the
-- client hasn't got yet — so pre-login the button is simply hidden until the level lands.)
function UpdateTalentButton()
if not TalentMicroButton or not QuestLogMicroButton then return end
if UnitLevel("player") < 10 then
TalentMicroButton:Hide()
QuestLogMicroButton:SetPoint("BOTTOMLEFT", "TalentMicroButton", "BOTTOMLEFT", 0, 0)
else
TalentMicroButton:Show()
QuestLogMicroButton:SetPoint("BOTTOMLEFT", "TalentMicroButton", "BOTTOMRIGHT", -3, 0)
end
end
function TalentMicroButton_OnLoad(button)
BenillaMicroButton_OnLoad(button, "Talents", TALENTS_BUTTON, "TOGGLETALENTS", NEWBIE_TOOLTIP_TALENTS)
button:RegisterEvent("UNIT_LEVEL")
button:RegisterEvent("PLAYER_ENTERING_WORLD")
end
function TalentMicroButton_OnEvent()
-- UNIT_LEVEL names its unit in arg1; every other unit's ding is none of our business.
if event == "UNIT_LEVEL" and arg1 ~= "player" then return end
UpdateTalentButton()
end
]]></Script>
<!-- ref MainMenuBarMicroButton (l.4-19): 29x58, with the top 18 inset out of the HIT rect — the
art only fills the lower ~40 px (verified against the 32x64 BLPs: the top ~22/64 rows are
fully transparent), and without the inset that empty header would eat hover and clicks
aimed at the bar's XP strip and the sliver of world above it. -->
<Button name="BenillaMicroButtonTemplate" virtual="true">
<Size><AbsDimension x="29" y="58"/></Size>
<HitRectInsets><AbsInset left="0" right="0" top="18" bottom="0"/></HitRectInsets>
<Scripts>
<OnEnter>BenillaMicroButton_OnEnter(self)</OnEnter>
<OnLeave>if GameTooltip then GameTooltip:Hide() end</OnLeave>
</Scripts>
</Button>
<!-- ref CharacterMicroButton (l.20-63): the row's head, at the art frame's BOTTOMLEFT +(552,2).
MicroButtonPortrait is the ref's own global name (not $parent-prefixed), 18x25 hung from the
button's TOP -28. -->
<Button name="CharacterMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuBarArtFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="552" y="2"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="MicroButtonPortrait">
<Size><AbsDimension x="18" y="25"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-28"/></Offset></Anchor></Anchors>
<TexCoords left="0.2" right="0.8" top="0.0666" bottom="0.9"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnLoad>CharacterMicroButton_OnLoad(self)</OnLoad>
<OnClick>ToggleCharacter("PaperDollFrame")</OnClick>
<OnUpdate>CharacterMicroButton_OnUpdate(self)</OnUpdate>
</Scripts>
</Button>
<Button name="SpellbookMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="CharacterMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Spellbook", SPELLBOOK_ABILITIES_BUTTON, "TOGGLESPELLBOOK", NEWBIE_TOOLTIP_SPELLBOOK)</OnLoad>
<OnClick>ToggleSpellBook(BOOKTYPE_SPELL)</OnClick>
</Scripts>
</Button>
<Button name="TalentMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="SpellbookMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>TalentMicroButton_OnLoad(self)</OnLoad>
<OnEvent>TalentMicroButton_OnEvent()</OnEvent>
<OnClick>ToggleTalentFrame()</OnClick>
</Scripts>
</Button>
<Button name="QuestLogMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="TalentMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<!-- The row's initial talent-gate pass runs here, not in TalentMicroButton_OnLoad: this
is the button UpdateTalentButton re-anchors, so it must already exist. (The ref gets
its first pass from PLAYER_ENTERING_WORLD, which we register too — this just means
the row is right before any event lands, e.g. under the capture harness.) -->
<OnLoad>BenillaMicroButton_OnLoad(self, "Quest", QUESTLOG_BUTTON, "TOGGLEQUESTLOG", NEWBIE_TOOLTIP_QUESTLOG); UpdateTalentButton()</OnLoad>
<OnClick>ToggleQuestLog()</OnClick>
</Scripts>
</Button>
<Button name="SocialsMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="QuestLogMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Socials", SOCIAL_BUTTON, "TOGGLESOCIAL", NEWBIE_TOOLTIP_SOCIAL)</OnLoad>
<OnClick>ToggleFriendsFrame(1)</OnClick>
</Scripts>
</Button>
<Button name="WorldMapMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="SocialsMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "World", WORLDMAP_BUTTON, "TOGGLEWORLDMAP", NEWBIE_TOOLTIP_WORLDMAP)</OnLoad>
<OnClick>ToggleWorldMap()</OnClick>
</Scripts>
</Button>
<!-- The game menu (decision 0674). `ToggleGameMenu(1)` is the ref's own OnClick — the argument
is what makes a CLICK a plain toggle (close it, or close everything and open it) instead of
the ESC key's one-eater-per-press ladder; UiPanels.xml has both halves. -->
<Button name="MainMenuMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="WorldMapMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "MainMenu", MAINMENU_BUTTON, nil, NEWBIE_TOOLTIP_MAINMENU)</OnLoad>
<OnClick>ToggleGameMenu(1)</OnClick>
</Scripts>
</Button>
<!-- The GM help window (HelpFrame.xml). `ToggleHelpFrame()` is the ref's own OnClick
(ref-MainMenuBarMicroButtons.xml l.217-219) and it is the whole wiring: the toggle takes
care of the panel manager, and the panel's own OnShow/OnHide call UpdateMicroButtons back
so this button's pushed state follows it however the window was opened or closed.
It takes no binding — 1.12 has no TOGGLEHELP action — so the tooltip shows the bare
label, which is the ref's behaviour for this one too. -->
<Button name="HelpMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Help", HELP_BUTTON, nil, NEWBIE_TOOLTIP_HELP)</OnLoad>
<OnClick>ToggleHelpFrame()</OnClick>
</Scripts>
</Button>
</Ui>
@@ -1,645 +0,0 @@
<!--
The HUD minimap cluster — our own implementation of what the 1.12 FrameXML Minimap.xml
specifies (decision 0203 phase 1), which is cited throughout for the names, the geometry, the
art and the event wiring: that half is the API addons reposition and hook by name, and none of
it is ours to invent. The <Minimap> widget itself is the engine-rendered map hole: benilla-ui carries its
rect + zoom, and the app (`minimap.rs`) fills it with the streamed tile window, the
MinimapMask circle, and the player arrow; everything in THIS file is the chrome the reference
authors around that hole, at the reference's own geometry and art.
IN (this file): the cluster frame (192×192, TOPRIGHT), MinimapBorderTop, the zone-text button
(label fed by MINIMAP_ZONE_CHANGED → GetMinimapZoneText()), the minimap show/hide toggle, the
140×140 <Minimap> widget, MinimapBackdrop's round border art, the zoom in/out buttons
driving Minimap:SetZoom() with the reference's enable/disable-at-bounds law + sounds, and the
unread-mail icon (MiniMapMailFrame, decision 0544 P3 — the mail arc's arrival layer) driven by
UPDATE_PENDING_MAIL + HasNewMail(), and the tracking icon (MiniMapTrackingFrame) driven by
PLAYER_AURAS_CHANGED + GetTrackingTexture() (the aura rebuild's tracking-global law, `ui_aura`).
The minimap PING (decision 1596) is IN as a click entry only: `Minimap_OnClick` below, on the
widget's OnMouseUp, exactly where the reference puts it — because one corpus addon
(CleanMinimap) REPLACES that global and expects the client to call the replacement. Everything
past the click is the engine's: the marker is pinned to a WORLD point and drawn by
`minimap/ping.rs` in the same pass as the blips, so there is no `MiniMapPing` frame, no Lua
timer and no per-frame SetPoint here. Owning the display in Lua is what made the first attempt
flaky, and no corpus addon touches any of that surface (measured: 0 of 218 for MiniMapPing /
Minimap_SetPing / MINIMAP_PING / PingLocation / GetPingPosition).
OUT (no wire / later arc slices, per 0203): meeting stone and battlefield buttons. The ping
MODEL is not out any more and never needed to be: the reference's MiniMapPing is a <Model> on
MinimapPing.mdx, and 1600 re-expresses its three drawn quads as flat animated sprites at the
model's own byte-measured sizes and tracks. (The "rotate-minimap CVar" this list used to name
does not EXIST in 5875 — a 214-site CVar::Register census found no such knob, and the address
it was pinned on, 0xceaa60, is the inside-a-WMO flag. Nothing to omit.) The day/night indicator
now lives in its own GameTime.xml (the reference's own file split), loaded right after this
file. The
reference's minimapPlayerModel/.mdx arrow renders app-side from its .blp for now (0203 flags
the simplification). The reference's <HitRectInsets> on the cluster is dropped (it only
enlarges the mouse-blocking hull; the loader models the tag now, so restore it if it ever
matters).
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The minimap click (decision 1596). A global, and thin on purpose: it is the seam an
-- addon replaces (CleanMinimap swaps it out to make a modifier-drag move the cluster and
-- calls the original otherwise), so everything it could get wrong is behind the engine.
-- The reference's own body also range-tests the disc and divides by GetEffectiveScale
-- here; both belong on the far side of PingLocation, which knows the map's real geometry
-- this frame — in UI units the widget's radius is not a number this file can know.
function Minimap_OnClick()
local x, y = GetCursorPosition();
local cx, cy = Minimap:GetCenter();
Minimap:PingLocation(x - cx, y - cy);
end
-- ref-Minimap.lua ToggleMinimap(), verbatim (the toggle button hides the MAP, not the chrome).
function ToggleMinimap()
if ( Minimap:IsVisible() ) then
PlaySound("igMiniMapClose");
Minimap:Hide();
else
PlaySound("igMiniMapOpen");
Minimap:Show();
end
end
-- The zone label's tint, keyed by the zone's PvP disposition. Colours quoted from
-- ref-Minimap.lua (they are measurements of the real interface, not choices of ours); the
-- table is ours, because five branches that differ only in three floats want to be data.
local ZONE_PVP_TINT = {
friendly = { r = 0.1, g = 1.0, b = 0.1 },
hostile = { r = 1.0, g = 0.1, b = 0.1 },
contested = { r = 1.0, g = 0.7, b = 0 },
};
-- Every line of the zone tooltip is drawn the same way — plain white, nothing in the
-- right-hand column — so the stanza is written once here instead of once per line.
local function AddZoneTooltipLine(text)
GameTooltip:AddLine(text, "", 1.0, 1.0, 1.0);
end
-- The zone's "whose land is this" line, or nil for a zone that is nobody's (sanctuary, or
-- a disposition we don't recognise). Free-for-all is deliberately NOT decided here: the
-- two callers below reach it by genuinely different routes and have to keep doing so — see
-- the note in Minimap_Update.
local function ZoneTerritoryLine(pvpType, factionName)
if ( pvpType == "friendly" or pvpType == "hostile" ) then
return format(FACTION_CONTROLLED_TERRITORY, factionName);
elseif ( pvpType == "contested" ) then
return CONTESTED_TERRITORY;
end
return nil;
end
-- Our implementation of what ref-Minimap.lua's Minimap_Update() specifies: relabel the
-- minimap's zone line, tint it for the zone's PVP state (decision 0287 — GetZonePVPInfo
-- closes the "zone PVP state we don't track yet" reduction that kept the label gold), and
-- refresh the hover tooltip if — and only if — the cursor is already parked on the label,
-- so a zone change under the mouse updates what is on screen.
function Minimap_Update()
local zoneName = GetMinimapZoneText();
MinimapZoneText:SetText(zoneName);
local pvpType, factionName, isArena = GetZonePVPInfo();
-- An arena zone is free-for-all, so it wears the hostile red whatever the zone's own
-- type says; resolving that override after the lookup keeps it in one place. A zone
-- with no tint of its own (sanctuary, or a type we don't know) stays normal gold.
local tint = ZONE_PVP_TINT[pvpType];
if ( isArena ) then
tint = ZONE_PVP_TINT.hostile;
end
if ( tint ) then
MinimapZoneText:SetTextColor(tint.r, tint.g, tint.b);
else
MinimapZoneText:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b);
end
if ( not GameTooltip:IsOwned(MinimapZoneTextButton) ) then
return;
end
-- AT MOST ONE territory line on this path, and free-for-all is an ALTERNATIVE to the
-- faction line here, reached off `pvpType` rather than off `isArena` — so with the
-- live API (which never reports "arena" as a type) it never fires at all. That is the
-- reference's own asymmetry with MinimapZoneTextButton_OnEnter below, where the FFA
-- line is an EXTRA line gated on `isArena`. The two disagree, they are kept apart
-- deliberately, and converging them would be a behaviour change wearing a tidy-up's
-- clothes — which is why ZoneTerritoryLine stops short of this arm.
local territory = ZoneTerritoryLine(pvpType, factionName);
if ( pvpType == "arena" ) then
territory = FREE_FOR_ALL_TERRITORY;
end
GameTooltip:SetOwner(MinimapZoneTextButton, "ANCHOR_LEFT");
AddZoneTooltipLine(zoneName);
if ( territory ) then
AddZoneTooltipLine(territory);
end
GameTooltip:Show();
end
-- The zone label's own hover tooltip. Minimap_Update above refreshes this same tooltip in
-- place while the cursor is parked here, but the two are NOT one routine: this one adds
-- the free-for-all line as an EXTRA line gated on `isArena` (so an arena that is also
-- contested says both), while Minimap_Update reaches it off `pvpType == "arena"` INSTEAD
-- of the faction line. See the note there; the difference is the reference's, it is what
-- players actually see, and it is preserved on purpose.
function MinimapZoneTextButton_OnEnter()
GameTooltip:SetOwner(this, "ANCHOR_LEFT");
AddZoneTooltipLine(GetMinimapZoneText());
local pvpType, factionName, isArena = GetZonePVPInfo();
local territory = ZoneTerritoryLine(pvpType, factionName);
if ( territory ) then
AddZoneTooltipLine(territory);
end
if ( isArena ) then
AddZoneTooltipLine(FREE_FOR_ALL_TERRITORY);
end
GameTooltip:Show();
end
-- The end of the track a step of `step` is heading for. The two ends are not spelled the
-- same way — index 0 is a constant, the tight end rides however many levels the widget
-- carries — which is the only reason the +/- buttons need saying twice at all.
local function MinimapZoomLimit(step)
if ( step < 0 ) then
return 0;
end
return Minimap:GetZoomLevels() - 1;
end
-- One zoom step, either direction (ref-Minimap.lua's two click handlers say this twice in
-- mirror image; benilla says it once and hands it the direction). Three things happen: the
-- OPPOSITE button is re-armed, because a step this way always leaves room to come back;
-- the widget moves; and this button greys if the step landed on its own end of the track.
-- Note the limit is read AFTER the step, so a widget that clamps the move is still asked
-- where it actually ended up.
local function MinimapZoomStep(step, sound, button, opposite)
opposite:Enable();
PlaySound(sound);
Minimap:SetZoom(Minimap:GetZoom() + step);
if ( Minimap:GetZoom() == MinimapZoomLimit(step) ) then
button:Disable();
end
end
function Minimap_ZoomInClick()
MinimapZoomStep(1, "igMiniMapZoomIn", MinimapZoomIn, MinimapZoomOut);
end
function Minimap_ZoomOutClick()
MinimapZoomStep(-1, "igMiniMapZoomOut", MinimapZoomOut, MinimapZoomIn);
end
-- Re-sync BOTH buttons to the zoom as it stands, which is what a click cannot do: a click
-- knows which way it moved, this does not. The app fires MINIMAP_UPDATE_ZOOM when the
-- active zoom index switches (stepping inside/outside flips to the other, independent
-- level), so the +/- greying follows the map you are actually on instead of the one you
-- left. Both ends are re-armed first and then at most one is greyed again — a widget with
-- a single zoom level sits on both ends at once, and the tight end wins that tie.
function Minimap_UpdateZoom()
MinimapZoomIn:Enable();
MinimapZoomOut:Enable();
local zoom = Minimap:GetZoom();
if ( zoom == MinimapZoomLimit(1) ) then
MinimapZoomIn:Disable();
elseif ( zoom == MinimapZoomLimit(-1) ) then
MinimapZoomOut:Disable();
end
end
-- Byte-exact 1.12 enUS GlobalString (GlobalStrings.lua:2122) — the unread-mail tooltip.
local HAVE_MAIL = "You have unread mail";
-- ref-Minimap.xml MiniMapMailFrame's OnEvent, verbatim logic (l.281-289): UPDATE_PENDING_MAIL
-- is the ONLY event the reference registers on this frame — no MAIL_INBOX_UPDATE/MAIL_CLOSED
-- exists there despite the icon logically depending on inbox state. That is not an oversight
-- to paper over: reading your mail clears the icon through the pending-mail countdown, never
-- through the inbox (opening a letter arms a deferred refresh; closing the mailbox re-asks
-- the server). `ui_mail::MailPending` models that countdown and fires UPDATE_PENDING_MAIL at
-- exactly the reference's three signal sites — decision 0913.
function MiniMapMailFrame_OnEvent()
if ( event == "UPDATE_PENDING_MAIL" ) then
if ( HasNewMail() ) then
this:Show();
else
this:Hide();
end
end
end
-- ref-Minimap.xml l.290-293, verbatim.
function MiniMapMailFrame_OnEnter()
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
GameTooltip:SetText(HAVE_MAIL);
end
-- The tracking icon, same shape as the mail frame above. `GetTrackingTexture()` is the
-- WHOLE of the state: the icon path of the one tracking effect the aura rebuild diverts
-- out of the buff bar (`ui_aura`), or nil when nothing is tracked — so there is nothing to
-- reconcile, the answer is either an icon to wear or a reason to get out of the way.
-- PLAYER_AURAS_CHANGED is the only signal that can change it (ref-Minimap.xml l.109-174
-- registers nothing else on this frame). Note the icon is NOT cleared on the way out: the
-- frame is hidden, and the next tracking effect repaints it before it is seen again.
function MiniMapTrackingFrame_OnEvent()
if ( event ~= "PLAYER_AURAS_CHANGED" ) then
return;
end
local tracking = GetTrackingTexture();
if ( not tracking ) then
MiniMapTrackingFrame:Hide();
return;
end
MiniMapTrackingIcon:SetTexture(tracking);
MiniMapTrackingFrame:Show();
end
-- Hovering the icon names the tracking spell itself, which the tooltip knows how to build.
function MiniMapTrackingFrame_OnEnter()
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
GameTooltip:SetTrackingSpell();
end
]]></Script>
<Frame name="MinimapCluster" frameStrata="BACKGROUND" enableMouse="true" parent="UIParent">
<Size>
<AbsDimension x="192" y="192"/>
</Size>
<Anchors>
<Anchor point="TOPRIGHT"/>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="MinimapBorderTop" file="Interface\Minimap\UI-Minimap-Border">
<Size>
<AbsDimension x="192" y="32"/>
</Size>
<Anchors>
<Anchor point="TOPRIGHT"/>
</Anchors>
<TexCoords left="0.25" right="1.0" top="0.0" bottom="0.125"/>
</Texture>
</Layer>
</Layers>
<Frames>
<Button name="MinimapZoneTextButton">
<Size>
<AbsDimension x="128" y="12"/>
</Size>
<Anchors>
<Anchor point="CENTER">
<Offset>
<AbsDimension x="-3" y="83"/>
</Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString name="MinimapZoneText" inherits="GameFontNormal" nonspacewrap="true">
<Size>
<AbsDimension x="128" y="12"/>
</Size>
<Anchors>
<Anchor point="TOP"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnEnter>
MinimapZoneTextButton_OnEnter();
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Button>
<Button name="MinimapToggleButton">
<Size>
<AbsDimension x="32" y="32"/>
</Size>
<Anchors>
<Anchor point="CENTER" relativePoint="TOPRIGHT">
<Offset>
<AbsDimension x="-15" y="-13"/>
</Offset>
</Anchor>
</Anchors>
<Scripts>
<OnClick>
ToggleMinimap();
</OnClick>
</Scripts>
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
<HighlightTexture alphaMode="ADD" file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight"/>
</Button>
<Minimap name="Minimap" enableMouse="true">
<Size>
<AbsDimension x="140" y="140"/>
</Size>
<Anchors>
<Anchor point="CENTER" relativePoint="TOP">
<Offset>
<AbsDimension x="9" y="-92"/>
</Offset>
</Anchor>
</Anchors>
<Frames>
<!-- ref-Minimap.xml l.109-174 for the names, geometry and art — the tracking
icon (the mail frame's nesting precedent: a child of the round Minimap
WIDGET, whose TOPLEFT the (-15,0) anchor hangs off). Driven by
PLAYER_AURAS_CHANGED →
GetTrackingTexture(): the reference's aura-cache rebuild diverts a
tracking-effect aura (Find Minerals, Track Beasts, …) out of the buff bar
into a tracking global this icon shows (byte-verified — the {0x2c,0x2d,0x97}
effect exclusion; `ui_aura` mirrors the law). Right-click cancels the
tracking aura, exactly the reference's CancelTrackingBuff. -->
<Frame name="MiniMapTrackingFrame" enableMouse="true" hidden="true">
<Size>
<AbsDimension x="33" y="33"/>
</Size>
<Anchors>
<Anchor point="TOPLEFT">
<Offset>
<AbsDimension x="-15" y="0"/>
</Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="MiniMapTrackingIcon">
<Size>
<AbsDimension x="26" y="26"/>
</Size>
<Anchors>
<Anchor point="TOPLEFT">
<Offset>
<AbsDimension x="7" y="-6"/>
</Offset>
</Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<Texture name="MiniMapTrackingBorder" file="Interface\Minimap\MiniMap-TrackingBorder">
<Size>
<AbsDimension x="64" y="64"/>
</Size>
<Anchors>
<Anchor point="TOPLEFT"/>
</Anchors>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnLoad>
this:RegisterEvent("PLAYER_AURAS_CHANGED");
</OnLoad>
<OnEvent>
MiniMapTrackingFrame_OnEvent();
</OnEvent>
<OnMouseUp>
if ( arg1 == "RightButton" ) then
CancelTrackingBuff();
end
</OnMouseUp>
<OnEnter>
MiniMapTrackingFrame_OnEnter();
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Frame>
<!-- ref-Minimap.xml l.239-298, verbatim (decision 0544 P3) — the unread-mail
icon. A child of the round Minimap WIDGET, not the cluster: its TOPRIGHT
(21,-38) hangs off the 140×140 map's right edge, which sits well inside the
cluster — hoisted to the cluster the same offset lands past the screen edge.
Unprefixed (this file's own convention: every frame here keeps the
reference's bare name, unlike the newer Benilla*-prefixed windows). -->
<!-- **Two more of the reference's minimap icons, built and hidden** — the
fourth time this posture has been the answer (1219's vertical multibars,
1223's bonus bar, ReputationWatchBar, these). `ref-Minimap.xml` declares
both `hidden="true"`: real on the client from load, shown only when there
is a battleground queue or a meeting stone to advertise.
benilla drives neither, so they stay hidden — but addons reposition the
whole icon set by NAME. `CleanMinimap.lua:209` is
`getglobal(frameName):ClearAllPoints()` over a list that includes both, and
it died on the nil; `MiniMapBattlefieldFrame` alone is 5 addons in the
corrected ranking (1218). Their icon regions are declared with them, since
an addon that moves a frame expects its named children to exist.
Geometry quoted from the reference: both 33x33 with an 18x18 icon at
TOPLEFT (7,-6), Battlefield at BOTTOMLEFT (13,-13) of the cluster and
MeetingStone at TOPRIGHT (23,-65). -->
<Button name="MiniMapBattlefieldFrame" enableMouse="true" hidden="true">
<Size><AbsDimension x="33" y="33"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="13" y="-13"/></Offset></Anchor>
</Anchors>
<!-- **All THREE of the reference's regions, on the reference's layers**
(ref-Minimap.xml l.311-349). This block used to carry only the icon,
which was the same half-step the note above warns against: "an addon
that moves a frame expects its named children to exist" is right, and
then one of the three was declared. pfUI's `modules/minimap.lua:80` is
`MiniMapBattlefieldBorder:Hide()`, unguarded — it hides the ring so the
icon sits flush on its square minimap — and pfQuest, pfQuest-turtle and
ShaguDPS all embed that module.
The LAYERS are the reference's too, and they are not decoration: the
icon is BACKGROUND and the border ARTWORK above it, which is the order
a ring around an icon has to draw in. Ours had the icon on ARTWORK, so
adding the border there would have raced it. -->
<Layers>
<Layer level="BACKGROUND">
<Texture name="MiniMapBattlefieldIcon" file="Interface\BattlefieldFrame\UI-Battlefield-Icon">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="-1" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<Texture name="MiniMapBattlefieldBorder" file="Interface\Minimap\MiniMap-TrackingBorder">
<Size><AbsDimension x="52" y="52"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer>
<Layer level="BORDER">
<!-- The queue-ready pulse. Nothing here drives it (benilla has no
battleground queue), and it is declared for the same reason its
two siblings are: `BattlegroundShine` is a global an addon can
read, and its absence is a nil rather than a missing effect. -->
<Texture name="BattlegroundShine" file="Interface\ComboFrame\ComboPoint" alphaMode="ADD">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="3" y="3"/></Offset></Anchor>
</Anchors>
<TexCoords left="0.5625" right="1" top="0" bottom="1"/>
</Texture>
</Layer>
</Layers>
</Button>
<Button name="MiniMapMeetingStoneFrame" enableMouse="true" hidden="true">
<Size><AbsDimension x="33" y="33"/></Size>
<Anchors>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="23" y="-65"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="MiniMapMeetingStoneIcon" file="Interface\Icons\INV_Misc_Rune_06">
<Size><AbsDimension x="18" y="18"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
</Button>
<Frame name="MiniMapMailFrame" enableMouse="true" hidden="true">
<Size>
<AbsDimension x="33" y="33"/>
</Size>
<Anchors>
<Anchor point="TOPRIGHT">
<Offset>
<AbsDimension x="21" y="-38"/>
</Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="MiniMapMailIcon" file="Interface\Icons\INV_Letter_15">
<Size>
<AbsDimension x="18" y="18"/>
</Size>
<Anchors>
<Anchor point="TOPLEFT">
<Offset>
<AbsDimension x="7" y="-6"/>
</Offset>
</Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<Texture name="MiniMapMailBorder" file="Interface\Minimap\MiniMap-TrackingBorder">
<Size>
<AbsDimension x="52" y="52"/>
</Size>
<Anchors>
<Anchor point="TOPLEFT"/>
</Anchors>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnLoad>
this:RegisterEvent("UPDATE_PENDING_MAIL");
this:SetFrameLevel(this:GetFrameLevel()+1)
</OnLoad>
<OnEvent>
MiniMapMailFrame_OnEvent();
</OnEvent>
<OnEnter>
MiniMapMailFrame_OnEnter();
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Frame>
<Frame name="MinimapBackdrop">
<Size>
<AbsDimension x="192" y="192"/>
</Size>
<Anchors>
<Anchor point="CENTER" relativeTo="MinimapCluster">
<Offset>
<AbsDimension x="0" y="-20"/>
</Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="MinimapBorder" file="Interface\Minimap\UI-Minimap-Border">
<TexCoords left="0.25" right="1.0" top="0.125" bottom="0.875"/>
</Texture>
</Layer>
</Layers>
<Frames>
<Button name="MinimapZoomIn">
<Size>
<AbsDimension x="32" y="32"/>
</Size>
<Anchors>
<Anchor point="CENTER">
<Offset>
<AbsDimension x="77" y="-13"/>
</Offset>
</Anchor>
</Anchors>
<Scripts>
<OnClick>
Minimap_ZoomInClick();
</OnClick>
</Scripts>
<NormalTexture file="Interface\Minimap\UI-Minimap-ZoomInButton-Up"/>
<PushedTexture file="Interface\Minimap\UI-Minimap-ZoomInButton-Down"/>
<DisabledTexture file="Interface\Minimap\UI-Minimap-ZoomInButton-Disabled"/>
<HighlightTexture alphaMode="ADD" file="Interface\Minimap\UI-Minimap-ZoomButton-Highlight"/>
</Button>
<Button name="MinimapZoomOut">
<Size>
<AbsDimension x="32" y="32"/>
</Size>
<Anchors>
<Anchor point="CENTER">
<Offset>
<AbsDimension x="51" y="-41"/>
</Offset>
</Anchor>
</Anchors>
<Scripts>
<OnClick>
Minimap_ZoomOutClick();
</OnClick>
</Scripts>
<NormalTexture file="Interface\Minimap\UI-Minimap-ZoomOutButton-Up"/>
<PushedTexture file="Interface\Minimap\UI-Minimap-ZoomOutButton-Down"/>
<DisabledTexture file="Interface\Minimap\UI-Minimap-ZoomOutButton-Disabled"/>
<HighlightTexture alphaMode="ADD" file="Interface\Minimap\UI-Minimap-ZoomButton-Highlight"/>
</Button>
</Frames>
</Frame>
</Frames>
<Scripts>
<!-- ref-Minimap.xml l.498: the ping is the widget's own OnMouseUp, on any
button (the reference tests no button here either). -->
<OnMouseUp>
Minimap_OnClick();
</OnMouseUp>
</Scripts>
</Minimap>
</Frames>
<Scripts>
<OnLoad>
Minimap_Update();
-- Seed the +/- state from the live zoom (default index 3 ⇒ both enabled) rather than
-- hardcoding a zoom-0 "can't zoom out" — the client's default is 3, not the widest.
Minimap_UpdateZoom();
this:RegisterEvent("MINIMAP_ZONE_CHANGED");
this:RegisterEvent("MINIMAP_UPDATE_ZOOM");
</OnLoad>
<OnEvent>
if ( event == "MINIMAP_UPDATE_ZOOM" ) then
Minimap_UpdateZoom();
else
Minimap_Update();
end
</OnEvent>
</Scripts>
</Frame>
</Ui>
-764
View File
@@ -1,764 +0,0 @@
<Ui>
<!--
THE MONEY FRAME KIT — `MoneyFrameTemplate` / `SmallMoneyFrameTemplate`, the `MoneyTypeInfo`
table and the `MoneyFrame_*` API that drives them.
This is **our implementation** of that kit. The specification it implements is the extracted
1.12 `Interface\FrameXML\MoneyFrame.lua` (356 lines) and `MoneyFrame.xml` (297), cited below
as `ref l.N`: the names, the published field names, the constants and every branch of the
collapse rule are the specification's, because addons call this by name and a compatible kit
has no freedom in any of that. How it is written — the structure, the helpers, the local
names, the comments — is ours.
WHY IT IS HERE: it is FrameXML's surface, not Rust's (decision 1190 — a name the shipped 1.12
UI defines belongs in a UI file, never hardcoded), and the addon corpus asks for it by name.
`MoneyFrame_SetType` is wanted by 11 addons, `MoneyFrame_Update` by 9, `SmallMoneyFrame_OnLoad`
by 9, and 13 declare a frame `inherits="SmallMoneyFrameTemplate"`. `MoneyTypeInfo` was one of
the top runtime walls the frame rename left standing.
── THE OTHER COIN KIT, and why there are still two ──────────────────────────────────────────
Eight of our windows (merchant, bank, trade, mail, quest, quest log, taxi, trainer) do NOT use
this file. They render coins through `BenillaMoney_Set`/`_Clear`/`_SetColor`, which live
below in this same file (they were in MerchantFrame.xml until 1751 took that window to the
stock chain): a three-SLOT chain (`<prefix>Coin1..3`) that fills only the non-zero
denominations in display order and re-flows, rather than three fixed GoldButton/SilverButton/
CopperButton frames that hide themselves. That kit takes its coin TexCoords, its collapse and
its shrink from the same specification as this one, and its layout is the one the director has
signed off across those eight windows.
The auction house is the exception, and the reason the reconciliation started there rather
than anywhere else: the three-slot kit has no notion of a money TYPE, so it cannot express
`showSmallerCoins` or `fixedWidth` — and those two flags ARE the auction house's price column
(`MoneyTypeInfo["AUCTION"]`, declared below and, until AuctionFrame.xml moved, used by
nothing). On the three-slot kit "1g" showed as one lone gold coin where the reference shows
`1g 0s 0c`, and a column of prices packed tight instead of lining up. That is not a layout
preference to re-approve, it is the kit being the wrong kit. See decision 1522.
So the two still coexist, and the split is now by KIT CAPABILITY as much as by audience:
· `BenillaMoney_*` + `<prefix>Coin1..3` — the eight windows whose look is signed off and
whose money is all plain collapsing STATIC amounts, which that kit renders correctly.
· `MoneyFrame_*` + `MoneyTypeInfo` here — the names and structure an addon writes against,
what an addon's `inherits="SmallMoneyFrameTemplate"` resolves to, and what the auction
house is laid out on.
Finishing the migration — moving those eight onto `SmallMoneyFrameTemplate` and retiring
`BenillaMoney_*` — is still the right end state; it is a re-layout of hand-authored art in
eight files (an approved look), so it stays named here rather than done in passing.
── WHAT IS NOT CARRIED, and why (each is guarded at its own site below) ─────────────────────
· THE COIN PICKUP. `OpenCoinPickupFrame`/`UpdateCoinPickupFrame`/`CoinPickupFrame` belong to
CoinPickupFrame.lua/.xml, which we do not ship — and the engine verbs underneath them
(`PickupPlayerMoney`, `DropCursorMoney`, `PickupTradeMoney`, `AddTradeMoney`) are not built
either, so a picked-up coin would have nowhere to go. The coin buttons still take the mouse
for a `canPickup` type (that is the specified state) and the click is guarded, so it does
nothing.
· `GetCursorMoney` (PLAYER's amount, ref l.17) — the engine verb for coins held on the cursor.
With no coin pickup there is no cursor money, so its term is not approximated away: it is
structurally zero. Stated at the spot.
· `GetSendMailMoney` / `GetSendMailCOD` (ref l.73, l.90) — 1.12 engine verbs we have not
built. We ship their SETTERS (`SetSendMailMoney`/`SetSendMailCOD`, which MailFrame.xml
drives); nothing reads the amount back yet, so those two types read 0.
· `message()` (ref l.145, l.173, l.181) — the one-line StaticPopup shim, which belongs to
BasicControls.xml, a file family we have not built. Guarded, so a misuse of the API stays
silent instead of erroring on the diagnostic.
· `Button:GetTextWidth()` (ref l.202 etc.) — BUILT, and called here, exactly as the
specification calls it. It forwards to the label FontString's `GetStringWidth`, which the
engine now answers inside the tick that asked (`script::measure`).
· The four `Pickup*`/`Add*` mail verbs the pickup closures name are not in 1.12's `_G` either
(they are absent from reference/1.12-globals.tsv) — so those closures are as unreachable
here as they are there. They exist because the table's shape is the contract.
-->
<Script><![CDATA[
-- ══ THE THREE-SLOT COIN KIT (`BenillaMoney_*`), rehomed from MerchantFrame.xml ═══════════
--
-- The OTHER kit — see this file's header for why there are two and what the split is. It
-- lives beside `MoneyFrame_*` rather than inside a window because eight windows share it:
-- merchant, bank, trade, mail, quest, quest log, taxi and trainer. Nothing about the kit
-- changed in the move; the `SmallMoneyFrameTemplate` migration the header names is still
-- outstanding and still the director's call.
-- The coin display, quoted from the real SmallMoneyFrame (ref MoneyFrame.xml/lua): a copper
-- amount renders as a number + a 13px coin icon per NONZERO denomination, cut from the one
-- shared texture Interface\MoneyFrame\UI-MoneyIcons by TexCoord (gold 0..0.25, silver
-- 0.25..0.5, copper 0.5..0.75 — ref l.115/74/33), the real collapse that hides zero coins
-- (MoneyFrame.lua l.221-267). Each money display owns three position-"slots"; the XML chains
-- them either left-to-right (row prices + the quest reward: Coin1 leftmost, gold first) or
-- right-to-left (the purses: Coin1 rightmost, copper first). BenillaMoney_Set fills the
-- visible denominations into slots 1.. in DISPLAY order (highestFirst=true for the
-- left-aligned chains) and — the real shrink, MoneyFrame.lua l.202/269 — sizes each slot to
-- exactly its number + its 13px coin (`SetWidth(GetTextWidth() + iconWidth)` there; the
-- same read here, off the number FontString the slot already owns). The static ±4px
-- chain-anchor offsets are the real MONEY_BUTTON_SPACING gap, so a shrunk group packs tight,
-- left-flush in the ltr chains and right-flush in the rtl ones. BenillaMoney_Clear hides
-- all three slots (the real collapse hides, see the helper).
--
-- NOTE (for the loot window's fold): the reusable helpers are
-- BenillaMoney_Set(prefix, copper) and BenillaMoney_Clear(prefix)
-- where `prefix` names the slot frames `<prefix>Coin1..3` (each with a `<prefix>CoinNIcon`
-- texture + `<prefix>CoinNNum` fontstring child). They live in this file.
--
-- They lived in MerchantFrame.xml until 1751 came for that window: the stock
-- `MerchantFrame.xml` off the player's chain has no room for eight other windows'
-- shared helpers, and this file is where the reference keeps money code anyway. A pure
-- move — not the `SmallMoneyFrameTemplate` migration named at the top of this file,
-- which is a re-layout of an approved look and is the director's to call.
BENILLA_COIN_TC = {
{ 0.0, 0.25 }, -- [1] gold
{ 0.25, 0.5 }, -- [2] silver
{ 0.5, 0.75 }, -- [3] copper
}
-- Unused slots HIDE (the real collapse — MoneyFrame.lua l.230/247 goldButton:Hide()), never
-- SetWidth(0): a 0 width means "derive from opposing anchors" to the layout (the client's
-- own G+0x50 convention), not "zero px". Fills are contiguous from Coin1, so a chain never
-- anchors THROUGH a hidden slot.
function BenillaMoney_Clear(prefix)
for i = 1, 3 do
getglobal(prefix .. "Coin" .. i):Hide()
end
end
function BenillaMoney_Set(prefix, copper, highestFirst)
copper = copper or 0
local vals = {
math.floor(copper / 10000), -- gold
math.floor((copper % 10000) / 100), -- silver
copper % 100, -- copper
}
-- Fill slots 1.. in display order (see the module comment); a zero denomination is
-- skipped (the real collapse), except copper always shows when the whole amount is
-- zero so a free item / empty purse isn't blank.
local first, last, step = 3, 1, -1
if highestFirst then first, last, step = 1, 3, 1 end
local slot = 0
for d = first, last, step do
if vals[d] > 0 or (d == 3 and vals[1] == 0 and vals[2] == 0) then
slot = slot + 1
-- The real shrink: this slot is exactly its number + its 13px coin, and the
-- XML anchor chain re-flows off the new width.
-- Text first, then measure it: the engine answers a width read inside the
-- tick that set the text, so the number IS this number and not the last one.
local num = getglobal(prefix .. "Coin" .. slot .. "Num")
num:SetText(vals[d])
local numW = num:GetStringWidth()
local slotFrame = getglobal(prefix .. "Coin" .. slot)
slotFrame:SetWidth(numW + 13)
slotFrame:Show()
local icon = getglobal(prefix .. "Coin" .. slot .. "Icon")
icon:SetTexture("Interface\\MoneyFrame\\UI-MoneyIcons")
icon:SetTexCoord(BENILLA_COIN_TC[d][1], BENILLA_COIN_TC[d][2], 0, 1)
num:SetWidth(numW)
end
end
for i = slot + 1, 3 do
getglobal(prefix .. "Coin" .. i):Hide()
end
end
-- Recolor the coin *numbers* (the ref's SetMoneyFrameColor — the digits redden, the coin icons
-- keep their own texture). The trainer detail cost calls this white when affordable / red
-- (1.0, 0.1, 0.1) when not; a plain purse never calls it, so its coins stay default. Applies to
-- all 3 slots (hidden ones are harmless) and must be re-called after each BenillaMoney_Set,
-- which fills the numbers but does not reset their color.
function BenillaMoney_SetColor(prefix, r, g, b)
for i = 1, 3 do
getglobal(prefix .. "Coin" .. i .. "Num"):SetTextColor(r, g, b)
end
end
-- ── The editable money-input kit (ref MoneyInputFrame.lua) ────────────────────────────────
-- A <prefix> naming three numeric EditBoxes <prefix>Gold/Silver/Copper — the shared money entry
-- both the send-mail and trade windows inline (decision 0592 P2 builds it here, the money home,
-- so both reuse it). GetCopper folds the three digits into a copper total; SetCopper splits a
-- copper amount back across the boxes. SetCopper is a *programmatic* fill, so it must not re-fire
-- a box's OnTextChanged value-changed callback (the loop the reference's `expectChanges` counter
-- guards, MoneyInputFrame.lua l.44-73). Because benilla fires OnTextChanged synchronously inside
-- SetText, a bracket flag is exact and simpler than the counter: a window's box calls
-- BenillaMoneyInput_Consume(prefix) at the top of its OnTextChanged — false (swallow) while a
-- SetCopper is in flight, true for a genuine keystroke.
BENILLA_MONEYINPUT_SUPPRESS = {}
function BenillaMoneyInput_ResetMoney(prefix)
getglobal(prefix .. "Gold"):SetText("")
getglobal(prefix .. "Silver"):SetText("")
getglobal(prefix .. "Copper"):SetText("")
end
function BenillaMoneyInput_GetCopper(prefix)
local function n(box) return tonumber(getglobal(prefix .. box):GetText()) or 0 end
return n("Gold") * 10000 + n("Silver") * 100 + n("Copper")
end
function BenillaMoneyInput_ClearFocus(prefix)
getglobal(prefix .. "Gold"):ClearFocus()
getglobal(prefix .. "Silver"):ClearFocus()
getglobal(prefix .. "Copper"):ClearFocus()
end
function BenillaMoneyInput_SetCopper(prefix, money)
money = money or 0
local parts = {
Gold = math.floor(money / 10000),
Silver = math.floor((money % 10000) / 100),
Copper = money % 100,
}
BENILLA_MONEYINPUT_SUPPRESS[prefix] = true
for box, val in pairs(parts) do
local eb = getglobal(prefix .. box)
if (tonumber(eb:GetText()) or 0) ~= val then
eb:SetText(val == 0 and "" or tostring(val))
end
end
BENILLA_MONEYINPUT_SUPPRESS[prefix] = false
end
function BenillaMoneyInput_Consume(prefix)
return not BENILLA_MONEYINPUT_SUPPRESS[prefix]
end
-- ── What a coin is worth ─────────────────────────────────────────────────────────────────
-- Published globals: an addon that formats a price does the same split we do below, and
-- reads these rather than spelling 100 twice.
COPPER_PER_SILVER = 100
SILVER_PER_GOLD = 100
COPPER_PER_GOLD = COPPER_PER_SILVER * SILVER_PER_GOLD
-- ── How wide a coin draws ────────────────────────────────────────────────────────────────
-- One coin icon's width at each of the two sizes the templates below come in, and the gap
-- between two coin buttons — NEGATIVE, because the three deliberately bite into each other
-- so a group reads as one number rather than as three. `COIN_BUTTON_WIDTH` is the fixed slot
-- a `fixedWidth` type pads its smaller coins out to, so a column of prices lines up.
-- The values are the specification's (ref l.2-12): measurements of the shipped coin art, not
-- ours to re-derive.
MONEY_ICON_WIDTH = 19
MONEY_ICON_WIDTH_SMALL = 13
MONEY_BUTTON_SPACING = -4
MONEY_BUTTON_SPACING_SMALL = -4
COIN_BUTTON_WIDTH = 32
-- ── The money types (ref l.14-103) ───────────────────────────────────────────────────────
--
-- A money frame carries no amount of its own: it carries a TYPE, and the type says where the
-- number comes from and how the coins behave. An entry has three parts:
--
-- · `UpdateFunc()` returns the amount, in copper. It is called with the global `this` bound
-- to the frame being painted, so a type may read the frame as well as the engine.
-- · `PickupFunc(amount)` / `DropFunc()` are the cursor verbs a click-to-pick-up type drives.
-- · the display flags `MoneyFrame_Update` reads: `collapse` (drop leading zero
-- denominations), `showSmallerCoins` (keep the trailing ones on even at zero; the value
-- "Backpack" additionally keeps copper on by itself), `canPickup` (the coins take the
-- mouse), `fixedWidth` (pad the smaller coins to COIN_BUTTON_WIDTH), `truncateSmallCoins`
-- (drop copper once there is gold).
--
-- Nothing else is consulted. That is why those field names and the two function signatures
-- are the published contract and not an implementation detail: addons register types of their
-- own into this table and expect exactly these parts to be honoured.
MoneyTypeInfo = { }
-- Read an amount through an engine verb that may not be built here yet: absent, it reads 0
-- rather than calling into nil. Two of the seven types below need it — see the header.
local function AmountFrom(verbName)
local verb = getglobal(verbName)
if verb then
return verb()
end
return 0
end
-- The purse. The specification subtracts a third term here (ref l.17,
-- `GetMoney() - GetCursorMoney() - GetPlayerTradeMoney()`): coins lifted onto the cursor by
-- the coin-pickup frame. benilla ships neither that frame nor the `PickupPlayerMoney` verb
-- behind it, so the term is structurally 0 rather than approximated away — it comes back the
-- moment either lands.
MoneyTypeInfo["PLAYER"] = {
UpdateFunc = function() return GetMoney() - GetPlayerTradeMoney() end,
PickupFunc = function(amount) PickupPlayerMoney(amount) end,
DropFunc = function() DropCursorMoney() end,
collapse = 1,
canPickup = 1,
showSmallerCoins = "Backpack",
}
-- A number the owner parked on the frame itself — what `MoneyFrame_Update(name, amount)`
-- leaves behind, and how an addon shows a price it worked out for itself.
MoneyTypeInfo["STATIC"] = {
UpdateFunc = function() return this.staticMoney end,
collapse = 1,
}
-- The auction house's price column: the same source as STATIC, but the smaller coins pad out
-- to a fixed slot and stay on at zero, so a stack of prices reads as a column of digits.
MoneyTypeInfo["AUCTION"] = {
UpdateFunc = function() return this.staticMoney end,
collapse = 1,
showSmallerCoins = "Backpack",
fixedWidth = 1,
truncateSmallCoins = nil,
}
-- Our side of an open trade, and the other party's. Only ours is click-to-pick-up.
MoneyTypeInfo["PLAYER_TRADE"] = {
UpdateFunc = function() return GetPlayerTradeMoney() end,
PickupFunc = function(amount) PickupTradeMoney(amount) end,
DropFunc = function() AddTradeMoney() end,
collapse = 1,
canPickup = 1,
}
MoneyTypeInfo["TARGET_TRADE"] = {
UpdateFunc = function() return GetTargetTradeMoney() end,
collapse = 1,
}
-- Mail postage and cash-on-delivery. Postage explicitly does NOT collapse (ref l.84): the
-- send-mail frame wants all three coins standing whatever the amount, because you are typing
-- into them. Both read 0 until their engine getters exist — see the header.
MoneyTypeInfo["SEND_MAIL"] = {
UpdateFunc = function() return AmountFrom("GetSendMailMoney") end,
PickupFunc = function(amount) PickupSendMailMoney(amount) end,
DropFunc = function() AddSendMailMoney() end,
collapse = nil,
canPickup = 1,
showSmallerCoins = "Backpack",
}
MoneyTypeInfo["SEND_MAIL_COD"] = {
UpdateFunc = function() return AmountFrom("GetSendMailCOD") end,
PickupFunc = function(amount) PickupSendMailCOD(amount) end,
DropFunc = function() AddSendMailCOD() end,
collapse = 1,
canPickup = 1,
}
-- ── Hearing about a change (ref l.105-140) ───────────────────────────────────────────────
--
-- Every money frame registers all five money events; each then decides whether the one that
-- arrived is about the number IT is showing. That pairing is data, not control flow, so it is
-- written as data: one row per event, in the order the templates register them, naming the
-- money types that event can change.
local MONEY_EVENTS = {
{ "PLAYER_MONEY", { PLAYER = 1 } },
{ "PLAYER_TRADE_MONEY", { PLAYER = 1, PLAYER_TRADE = 1 } },
{ "TRADE_MONEY_CHANGED", { TARGET_TRADE = 1 } },
{ "SEND_MAIL_MONEY_CHANGED", { PLAYER = 1, SEND_MAIL = 1 } },
{ "SEND_MAIL_COD_CHANGED", { PLAYER = 1, SEND_MAIL_COD = 1 } },
}
-- The same rows keyed by event name, built once, so the handler below is a lookup.
local moneyEventTypes = { }
for _, row in ipairs(MONEY_EVENTS) do
moneyEventTypes[row[1]] = row[2]
end
local function MoneyFrameListen(frame)
for _, row in ipairs(MONEY_EVENTS) do
frame:RegisterEvent(row[1])
end
end
-- The two OnLoads differ in exactly one thing: `small`, which is what picks the 13px coin and
-- its tighter spacing over the 19px one in MoneyFrame_Update below. Both then take type
-- PLAYER, so a frame nobody tells otherwise shows the purse.
function MoneyFrame_OnLoad()
MoneyFrameListen(this)
MoneyFrame_SetType("PLAYER")
end
function SmallMoneyFrame_OnLoad()
MoneyFrameListen(this)
this.small = 1
MoneyFrame_SetType("PLAYER")
end
function MoneyFrame_OnEvent()
if not this.info or not this:IsVisible() then
return
end
local affected = moneyEventTypes[event]
if affected and affected[this.moneyType] then
MoneyFrame_UpdateMoney()
end
end
-- ── Painting ─────────────────────────────────────────────────────────────────────────────
-- A money frame's three coin buttons, biggest first. Every verb below addresses them as a
-- trio, so this is the one place their `$parent<Denomination>Button` names are spelled.
local function MoneyFrameCoins(frameName)
return getglobal(frameName .. "GoldButton"),
getglobal(frameName .. "SilverButton"),
getglobal(frameName .. "CopperButton")
end
-- Point a frame at a money type (ref l.142-162): remember it, wire the mouse to match, and
-- repaint. Reads `this` rather than taking a frame — that is the convention the eleven corpus
-- callers write against.
function MoneyFrame_SetType(type)
local info = MoneyTypeInfo[type]
if not info then
-- ref l.145 diagnoses this with `message()`, which we do not ship (see the header).
if message then
message("Invalid money type: " .. type)
end
return
end
this.info = info
this.moneyType = type
-- Only a click-to-pick-up type wants the mouse; on the rest a click falls through to
-- whatever is behind the coins.
local gold, silver, copper = MoneyFrameCoins(this:GetName())
local pickable = info.canPickup and true or false
gold:EnableMouse(pickable)
silver:EnableMouse(pickable)
copper:EnableMouse(pickable)
MoneyFrame_UpdateMoney()
end
-- Re-read the amount from the type and repaint (ref l.164-175). `this` again.
function MoneyFrame_UpdateMoney()
if not this.info then
if message then
message("Error moneyType not set")
end
return
end
local money = this.info.UpdateFunc()
MoneyFrame_Update(this:GetName(), money)
-- ref l.169-171 also feeds the coin-pickup frame while one is open. `hasPickup` is set
-- only by the coin click, which is guarded off (see the header), so this is unreachable
-- today rather than removed.
if this.hasPickup == 1 and UpdateCoinPickupFrame then
UpdateCoinPickupFrame(money)
end
end
-- Lay one denomination out: its digits, and the width those digits plus the coin icon need.
--
-- `Button:GetTextWidth()` right after `SetText`, which is what the specification writes
-- (ref l.202 and kin) and what the real client answers synchronously off its inline font
-- engine, so the coin is the right width on the frame that set its text.
--
-- This read used to return 0 — our measure landed at extract, a frame after the tick — and
-- left all three coins at exactly one bare icon width with their digits overlapping: the
-- director's "gold numbers are all cramped up when I first open the bags; close and open
-- again and it looks good", the reopen being the first open's cached measure. It was served
-- for a while by a digits-only advance table, since a number's width is a pure sum of
-- per-glyph advances. The engine answers the general question now (`script::measure`), so
-- this asks it the way the specification does.
local function ShowCoin(coin, coinName, amount, iconWidth)
coin:SetText(amount)
coin:SetWidth(coin:GetTextWidth() + iconWidth)
coin:Show()
end
-- Chain the next-bigger coin onto `coin`, and report what `coin` contributes to the group's
-- width.
--
-- Showing: the bigger coin hangs off this one's LEFT edge by `spacing` (negative — they
-- overlap), and the group grows by this coin's width less that overlap, but only when the
-- bigger coin is actually on screen to overlap with. Not showing: this coin hides and the
-- bigger one collapses onto its RIGHT edge, contributing nothing. `padToSlot` is the
-- `fixedWidth` exception, which sizes the coin to the fixed column slot first.
local function ChainCoin(bigger, coin, coinName, showing, padToSlot, spacing)
if not showing then
coin:Hide()
bigger:SetPoint("RIGHT", coinName, "RIGHT", 0, 0)
return 0
end
if padToSlot then
coin:SetWidth(COIN_BUTTON_WIDTH)
end
local grown = coin:GetWidth()
bigger:SetPoint("RIGHT", coinName, "LEFT", spacing, 0)
if bigger:IsVisible() then
grown = grown - spacing
end
return grown
end
-- THE PAINTER (ref l.177-270). Split a copper amount into the three denominations, size each
-- coin to its own digits plus its icon, and then — if the type collapses — drop the leading
-- zeros, re-chain what is left so the group packs tight, and report the frame's true width.
--
-- This one takes a frame NAME, not `this`: it is the only entry point in the family an owner
-- can call about a frame other than the one currently dispatching, which is how an addon
-- paints a STATIC frame it has just filled.
function MoneyFrame_Update(frameName, money)
local frame = getglobal(frameName)
local info = frame.info
if not info then
-- ref l.181 diagnoses and, notably, does NOT return: the coins below are still laid
-- out, and the collapse read further down is what actually stops it. Kept as-is.
if message then
message("Error moneyType not set")
end
end
-- Copper is the low two digits, silver the next two, gold everything above. Silver comes
-- out by subtracting the gold back off rather than by a second `mod`: `mod` here is 1.12's
-- `math.mod`, which truncates towards zero, so `mod(-1, 100)` is -1 and not 99 — the two
-- forms part company the moment `money` goes negative (which `GetMoney()` minus a trade
-- offer can), and this one is the specified answer.
-- A STATIC/AUCTION frame's amount is whatever its owner last parked on it, and on the
-- very first paint — `MoneyFrame_SetType`'s own repaint, in OnLoad, before any owner has
-- filled it — that is nil. The specification divides straight into it and throws there;
-- we answer 0, because the throw would take the whole `<OnLoad>`/`<OnShow>` chain down
-- with it and an empty price column is the honest reading of "nothing set yet".
money = money or 0
local gold = floor(money / COPPER_PER_GOLD)
local silver = floor((money - gold * COPPER_PER_GOLD) / COPPER_PER_SILVER)
local copper = mod(money, COPPER_PER_SILVER)
local goldCoin, silverCoin, copperCoin = MoneyFrameCoins(frameName)
local iconWidth, spacing = MONEY_ICON_WIDTH, MONEY_BUTTON_SPACING
if frame.small then
iconWidth, spacing = MONEY_ICON_WIDTH_SMALL, MONEY_BUTTON_SPACING_SMALL
end
ShowCoin(goldCoin, frameName .. "GoldButton", gold, iconWidth)
ShowCoin(silverCoin, frameName .. "SilverButton", silver, iconWidth)
ShowCoin(copperCoin, frameName .. "CopperButton", copper, iconWidth)
-- Remember what the frame is displaying — STATIC's own reader reads it back.
frame.staticMoney = money
-- A type that does not collapse is finished: all three coins stand, at the width their
-- digits asked for, and the frame keeps whatever width it was given.
if not info.collapse then
return
end
-- Gold leads. Present, it stays and sets the terms for the two below it; absent, it goes.
-- The running width starts at one icon's worth of frame padding.
local width = iconWidth
local keepSmaller, dropCopper
if gold > 0 then
width = width + goldCoin:GetWidth()
keepSmaller = info.showSmallerCoins and 1 or nil
dropCopper = info.truncateSmallCoins and 1 or nil
else
goldCoin:Hide()
end
-- Silver, then copper: each either shows — padded, chained and counted — or hides and lets
-- the coin above it collapse onto its edge. Silver showing under a `showSmallerCoins` type
-- is what carries the exemption down to copper; copper also survives at zero for a
-- "Backpack" type, and dies whatever it is once gold truncated it.
local showSilver = silver > 0 or keepSmaller
width = width + ChainCoin(goldCoin, silverCoin, frameName .. "SilverButton",
showSilver, keepSmaller and info.fixedWidth, spacing)
if showSilver and info.showSmallerCoins then
keepSmaller = 1
end
local showCopper = (copper > 0 or keepSmaller or info.showSmallerCoins == "Backpack")
and not dropCopper
width = width + ChainCoin(silverCoin, copperCoin, frameName .. "CopperButton",
showCopper, keepSmaller and info.fixedWidth, spacing)
frame:SetWidth(width)
end
-- A NO-OP, deliberately. The specification's whole body for this one sits inside a `--[[ ]]`
-- block (ref l.272-346) — an earlier painter that MoneyFrame_Update replaced. The name is in
-- the shipped client's `_G`, and an addon that calls it there gets exactly this: nothing.
function RefreshMoneyFrame(frameName, money, small, collapse, showSmallerCoins)
end
-- Recolour the DIGITS; the coin icons keep their own texture (ref l.348-356). This is how a
-- price you cannot afford turns red.
function SetMoneyFrameColor(frameName, r, g, b)
local gold, silver, copper = MoneyFrameCoins(frameName)
gold:SetTextColor(r, g, b)
silver:SetTextColor(r, g, b)
copper:SetTextColor(r, g, b)
end
-- A click on a coin opens the pickup frame on that denomination, seeded with what the frame is
-- currently showing; `denomination` is one coin's worth in copper, which is the unit the
-- pickup frame counts in.
--
-- Guarded, because we ship no coin pickup (see the header) — so today the click does nothing,
-- and the mouse-enabled state a `canPickup` type gives the coins is all that is observable.
-- Note the amount is read by calling the type's own `UpdateFunc` while `this` is still the
-- BUTTON rather than the frame, which is what the specification's own coin `<OnClick>` does
-- (ref MoneyFrame.xml l.20-23) and what a frame-reading type would see through.
function BenillaMoneyFrameCoin_OnClick(coin, denomination)
if not OpenCoinPickupFrame then
return
end
local frame = coin:GetParent()
OpenCoinPickupFrame(denomination, MoneyTypeInfo[frame.moneyType].UpdateFunc(), frame)
frame.hasPickup = 1
end
-- Closing a money frame takes any pickup it opened down with it.
function BenillaMoneyFrame_OnHide()
if this.hasPickup == 1 and CoinPickupFrame then
CoinPickupFrame:Hide()
this.hasPickup = 0
end
end
]]></Script>
<!--
MoneyFrameTemplate (ref MoneyFrame.xml l.3-149) — the LARGE kit: 192x28, 19px coins, the
NumberFontNormalLarge digits. The three buttons chain RIGHT-to-LEFT from the copper end, which
is what lets MoneyFrame_Update above re-point them past a hidden denomination.
`<PushedTextOffset>` is carried for provenance; our loader does not model it and ignores it.
-->
<Frame name="MoneyFrameTemplate" virtual="true">
<Size><AbsDimension x="192" y="28"/></Size>
<Frames>
<Button name="$parentCopperButton">
<Size><AbsDimension x="47" y="19"/></Size>
<Anchors>
<Anchor point="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, 1)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="19" y="19"/></Size>
<Anchors><Anchor point="RIGHT"/></Anchors>
<TexCoords left="0.5" right="0.75" top="0" bottom="1"/>
</NormalTexture>
<ButtonText name="$parentText">
<Anchors>
<Anchor point="RIGHT"><Offset><AbsDimension x="-19" y="0"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="NumberFontNormalLarge" justifyH="RIGHT"/>
<PushedTextOffset><AbsDimension x="0" y="0"/></PushedTextOffset>
</Button>
<Button name="$parentSilverButton">
<Size><AbsDimension x="47" y="19"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="$parentCopperButton" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, COPPER_PER_SILVER)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="19" y="19"/></Size>
<Anchors><Anchor point="RIGHT"/></Anchors>
<TexCoords left="0.25" right="0.5" top="0" bottom="1"/>
</NormalTexture>
<ButtonText name="$parentText">
<Anchors>
<Anchor point="RIGHT"><Offset><AbsDimension x="-19" y="0"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="NumberFontNormalLarge" justifyH="RIGHT"/>
<PushedTextOffset><AbsDimension x="0" y="0"/></PushedTextOffset>
</Button>
<Button name="$parentGoldButton">
<Size><AbsDimension x="47" y="19"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="$parentSilverButton" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, COPPER_PER_GOLD)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="19" y="19"/></Size>
<Anchors><Anchor point="RIGHT"/></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="1"/>
</NormalTexture>
<ButtonText name="$parentText">
<Anchors>
<Anchor point="RIGHT"><Offset><AbsDimension x="-19" y="0"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="NumberFontNormalLarge" justifyH="RIGHT"/>
<PushedTextOffset><AbsDimension x="0" y="0"/></PushedTextOffset>
</Button>
</Frames>
<Scripts>
<OnLoad>MoneyFrame_OnLoad()</OnLoad>
<OnEvent>MoneyFrame_OnEvent()</OnEvent>
<OnShow>MoneyFrame_UpdateMoney()</OnShow>
<OnHide>BenillaMoneyFrame_OnHide()</OnHide>
</Scripts>
</Frame>
<!--
SmallMoneyFrameTemplate (ref MoneyFrame.xml l.150-296) — the same kit at 128x13 with 13px
coins and NumberFontNormal digits, and `SmallMoneyFrame_OnLoad` in place of `MoneyFrame_OnLoad`
so `this.small` is set. This is the one the shipped UI uses nearly everywhere (merchant prices,
the purse, mail postage, trade offers) and the one 13 corpus addons inherit.
-->
<Frame name="SmallMoneyFrameTemplate" virtual="true">
<Size><AbsDimension x="128" y="13"/></Size>
<Frames>
<Button name="$parentCopperButton">
<Size><AbsDimension x="32" y="13"/></Size>
<Anchors>
<Anchor point="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, 1)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT"/></Anchors>
<TexCoords left="0.5" right="0.75" top="0" bottom="1"/>
</NormalTexture>
<ButtonText name="$parentText">
<Anchors>
<Anchor point="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="NumberFontNormal" justifyH="RIGHT"/>
<PushedTextOffset><AbsDimension x="0" y="0"/></PushedTextOffset>
</Button>
<Button name="$parentSilverButton">
<Size><AbsDimension x="32" y="13"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="$parentCopperButton" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, COPPER_PER_SILVER)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT"/></Anchors>
<TexCoords left="0.25" right="0.5" top="0" bottom="1"/>
</NormalTexture>
<ButtonText name="$parentText">
<Anchors>
<Anchor point="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="NumberFontNormal" justifyH="RIGHT"/>
<PushedTextOffset><AbsDimension x="0" y="0"/></PushedTextOffset>
</Button>
<Button name="$parentGoldButton">
<Size><AbsDimension x="32" y="13"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="$parentSilverButton" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, COPPER_PER_GOLD)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT"/></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="1"/>
</NormalTexture>
<ButtonText name="$parentText">
<Anchors>
<Anchor point="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="NumberFontNormal" justifyH="RIGHT"/>
<PushedTextOffset><AbsDimension x="0" y="0"/></PushedTextOffset>
</Button>
</Frames>
<Scripts>
<OnLoad>SmallMoneyFrame_OnLoad()</OnLoad>
<OnEvent>MoneyFrame_OnEvent()</OnEvent>
<OnShow>MoneyFrame_UpdateMoney()</OnShow>
<OnHide>BenillaMoneyFrame_OnHide()</OnHide>
</Scripts>
</Frame>
</Ui>
-532
View File
@@ -1,532 +0,0 @@
<Ui>
<!--
**The four extra action bars — MultiBarBottomLeft/BottomRight and the vertical
MultiBarRight/MultiBarLeft — are PLAYER OPTIONS, and all four ship OFF (decision 1500,
which reverses 0270 and the half of 1219 that made "hidden" unconditional).**
0270 made the two bottom bars unconditionally on and the toggles unshippable; 1219 added
the two vertical ones as unconditionally hidden frames for the addons that index them. Both
were the director's call at the time, and 1500 is the director's call now: the four bars
become rows on the Options window's Action Bars page, and a fresh character sees none of
them.
THE STORE IS THE SERVER'S, not a CVar and not a saved variable — verified against the real
1.12.1 binary by a wow-re §5 cross-check (three independent derivations, arbitrated;
wow-5875-re commit 417c2d31, node system/ui/scratch/action-bar-toggles.md):
- The four bits live in **PLAYER_FIELD_BYTES byte 2**, written with
**CMSG_SET_ACTIONBAR_TOGGLES (opcode 703)**. Per-character and server-persisted, which is
what makes "off by default" fall out for free: a fresh character's byte is 0.
- `SetActionBarToggles` takes exactly **FOUR** arguments (`cmp esi,4` at 0x4e770e) and packs
them as arg1→0x01, arg2→0x02, arg3→0x04, arg4→0x08. **A FIFTH argument is silently
DROPPED by the binding** — it never reaches the byte or the wire — even though the
reference's own options panel passes ALWAYS_SHOW_MULTIBARS as one. That is why the grid
option below is a saved variable and the four bars are not: the split is a verified
consequence of the binding's arity, not an authoring choice.
- `GetActionBarToggles` returns exactly FOUR values, each the Lua number `1` or `nil` —
never booleans, and four nils when not in world.
- The client NEVER writes that field locally and NOTHING fires on a change to it. So the
LIVE state is the four Lua globals below; `GetActionBarToggles()` is read ONCE, on
PLAYER_ENTERING_WORLD (UIParent.xml), purely as their seed. Nothing re-reads it.
The bar↔bit mapping is a FrameXML-layer rule — the binary is bar-agnostic: 0x01 →
MultiBarBottomLeft, 0x02 → MultiBarBottomRight, 0x04 → MultiBarRight, 0x08 → MultiBarLeft,
and **MultiBarLeft shows only when bar 3 is ALSO on** (confirmed by reading the install's
own MultiActionBars.lua l.73, `if ( SHOW_MULTI_ACTIONBAR_3 and SHOW_MULTI_ACTIONBAR_4 )`).
That conjunction is not arbitrary: MultiBarLeft's TOPRIGHT hangs off MultiBarRight's
TOPLEFT, so on its own it would be a column of buttons anchored to a bar that is not there.
Geometry + slot ranges QUOTED from the real 1.12 FrameXML (extracted from patch.MPQ):
- ref-MultiActionBars.xml l.36-39/496-513: each bar a 500x38 frame; BottomLeft's
BOTTOMLEFT anchors to ActionButton1's TOPLEFT +(0,17); BottomRight's LEFT anchors to
BottomLeft's RIGHT +(10,0); 12 buttons chained BOTTOMLEFT then LEFT-of-prev-RIGHT +6.
- ref-ActionButton.lua l.6-9/446-467 (ActionButton_GetPagedID): BottomLeft = page 6 ⇒
actions 61..72; BottomRight = page 5 ⇒ actions 49..60. The bases below feed
BenillaActionButton_Action (ActionBar.xml), which carries the ref's parent-name fork
as per-button data.
- Buttons reuse BenillaActionButtonTemplate + the whole ActionBar.xml handler set: same
click/drag/keyless-use, same feedback layer (state/usable/cooldown/flash), fanned from
MainMenuBar's bar-level events via the BENILLA_ACTION_BUTTONS roster each button
joins at OnLoad.
Ref divergences (deliberate, mirroring ActionBar.xml's SCOPE notes):
- MULTIACTIONBAR1/2BUTTON1-12 are BINDABLE since 1008 (the ref's own commands, registered
in crate::bindings::commands with the ref's runOnUp pair below); they ship UNBOUND like
the ref (no MULTIACTIONBAR* line in any of the install's bindings-cache.wtf files), so
out of the box the hotkey corner still carries the ref's range dot — a bound slot wears
its abbreviated label exactly like the main bar (ActionBar.xml's PaintHotkey law).
- Empty wells hide (the ref's own default) and appear as drop-target rings only while the
grid is asked for — a held cursor payload, or ALWAYS_SHOW_MULTIBARS below — unlike the
main bar's always-visible wells (its art has sockets; floating rings over bare world
would not read as a bar).
- MULTIACTIONBAR3/4BUTTON (the vertical bars' commands) still stay OUT of
`bindings::commands`. 1008's honest tree held while those bars could never be shown, and
it holds differently now that they can: the keybind screen lists what the player can
reach, and a bar behind an option is reachable. Registering them is a separate slice with
its own default-bindings question; until then painting a hotkey from an unregistered
command is a no-op (GetBindingKey answers nil) and the label is simply blank.
-->
<Script><![CDATA[
-- ref ActionButton.lua:6-7 — BOTTOMLEFT_ACTIONBAR_PAGE = 6, BOTTOMRIGHT_ACTIONBAR_PAGE = 5;
-- base = (page - 1) * 12.
BENILLA_MULTIBAR_BOTTOMLEFT_BASE = 60
BENILLA_MULTIBAR_BOTTOMRIGHT_BASE = 48
-- ref ActionButton.lua:8-9 — LEFT_ACTIONBAR_PAGE = 4, RIGHT_ACTIONBAR_PAGE = 3.
BENILLA_MULTIBAR_LEFT_BASE = 36
BENILLA_MULTIBAR_RIGHT_BASE = 24
-- ref MultiActionBars.lua:1 — every extra bar is twelve wells wide.
NUM_MULTIBAR_BUTTONS = 12
-- ── The four toggles: LIVE state, seeded once from the server byte ──────────────────────
-- These names are the REFERENCE's and they are a published contract, not a private cache —
-- the addon corpus reads and writes them directly. Bartender2.lua:45-48 sets all four to 1
-- at load (it takes the bars over), and CT_BarMod reads each one by name in three separate
-- places to decide whether a button of ours is its business (CT_BarMod.lua:232-235,
-- 1573-1576, 1624-1627). So the names are fixed and the VALUES have to be what the corpus
-- expects: 1 or nil, exactly what GetActionBarToggles returns, never true/false.
--
-- nil here = OFF, which is also the whole of "off by default": a fresh character's server
-- byte is 0, and the seed on PLAYER_ENTERING_WORLD (UIParent.xml) writes these four back
-- to nil. Declared explicitly anyway rather than left absent — an undeclared global reads
-- nil too, but then nothing in this file says the state exists.
SHOW_MULTI_ACTIONBAR_1 = nil
SHOW_MULTI_ACTIONBAR_2 = nil
SHOW_MULTI_ACTIONBAR_3 = nil
SHOW_MULTI_ACTIONBAR_4 = nil
-- The FIFTH switch, and the one with a different store — see the header: the engine
-- binding drops a fifth argument, so this one has no server side and is an ordinary
-- saved-variable global, exactly as the reference makes it (UIOptionsFrame.lua l.106-107
-- assigns "0" and RegisterForSave's it). It holds the four bars' EMPTY wells open so the
-- player can see the shape of a bar they have not filled yet. String "1"/"0", because that
-- is what the options row's uvar store writes; zBar reads it as either
-- (zActionButton.lua:61 tests both spellings) and so does the reader below.
--
-- This assignment is also the row's registered DEFAULT: OptionsGlobalRow_OnLoad captures
-- the file-scope value at load, so this line is the single source of it.
ALWAYS_SHOW_MULTIBARS = "0"
-- The four bars in toggle (and bit) order, each paired with the main-bar page it takes OUT
-- of the paging cycle while it is up — a page a multibar already displays must not also be
-- reachable on the main bar, or paging up shows you the twelve actions already on screen
-- beside it. ActionBar.xml declares VIEWABLE_ACTION_BAR_PAGES with all six viewable (all
-- four bars start hidden) and this file does the arithmetic from there.
BENILLA_MULTIBARS = {
{frame = "MultiBarBottomLeft", page = BOTTOMLEFT_ACTIONBAR_PAGE},
{frame = "MultiBarBottomRight", page = BOTTOMRIGHT_ACTIONBAR_PAGE},
{frame = "MultiBarRight", page = RIGHT_ACTIONBAR_PAGE},
{frame = "MultiBarLeft", page = LEFT_ACTIONBAR_PAGE},
}
-- One bar: on screen or not, and the main-bar page it owns settled to match. Split out so
-- MultiActionBar_Update below reads as the four-line list it actually is — which is where
-- the one thing a reader must not miss lives (bar 4's compound condition).
function BenillaMultiBar_Settle(bar, up)
local frame = getglobal(bar.frame)
if up then
frame:Show()
VIEWABLE_ACTION_BAR_PAGES[bar.page] = nil
else
frame:Hide()
VIEWABLE_ACTION_BAR_PAGES[bar.page] = 1
end
end
-- Re-derive all four bars from the four globals. Every writer of that state calls this —
-- the options rows, the PLAYER_ENTERING_WORLD seed, and any addon that sets a global and
-- asks for the bars back (the reference's own contract, and Bartender2 relies on it).
--
-- **The manage pass runs from HERE, not from per-bar OnShow/OnHide**, and that is a choice
-- worth naming because both work. A bar coming up or going down moves the cast bar, the
-- chat frames, the stance/pet row and the bag stack's corner
-- (UIPARENT_MANAGED_FRAME_POSITIONS), and the pass reads the bars' own :IsShown() rather
-- than these globals — so it must run AFTER the last Show/Hide, not four times in the
-- middle of them. One call at the end of the one function that moves them is that; four
-- OnShow/OnHide hooks would fire the pass up to four times per click, and three of those
-- times against a half-applied state. The reference reaches the same place from the other
-- side: its options panel calls MultiActionBar_Update() and UIParent_ManageFramePositions()
-- back to back at every one of its five click sites (UIOptionsFrame.xml l.665-666 et al).
function MultiActionBar_Update()
local bars = BENILLA_MULTIBARS
BenillaMultiBar_Settle(bars[1], SHOW_MULTI_ACTIONBAR_1)
BenillaMultiBar_Settle(bars[2], SHOW_MULTI_ACTIONBAR_2)
BenillaMultiBar_Settle(bars[3], SHOW_MULTI_ACTIONBAR_3)
-- Bar 4 is the one bar with a COMPOUND condition (ref MultiActionBars.lua l.73) — see
-- the header for why MultiBarLeft cannot stand without MultiBarRight under it.
BenillaMultiBar_Settle(bars[4], SHOW_MULTI_ACTIONBAR_3 and SHOW_MULTI_ACTIONBAR_4)
UIParent_ManageFramePositions()
end
-- ── The empty-well grid over the four bars ──────────────────────────────────────────────
-- The three verbs are the reference's own published names and signatures, and the corpus
-- is why they are worth shipping as such: CT_BarMod REPLACES MultiActionBar_UpdateGrid
-- wholesale (CT_BarMod.lua:1649) and zBar WRAPS ShowAllGrids/HideAllGrids around its own
-- bars (zActionButton.lua:83-94), both by name and both expecting this shape. The
-- per-button half lives in ActionBar.xml (ActionButton_ShowGrid/_HideGrid over
-- `button.showgrid`), which is where the reference keeps it too.
function MultiActionBar_UpdateGrid(barName, show)
for i = 1, NUM_MULTIBAR_BUTTONS do
local button = getglobal(barName .. "Button" .. i)
if show then
ActionButton_ShowGrid(button)
else
ActionButton_HideGrid(button)
end
end
end
function MultiActionBar_ShowAllGrids()
for _, bar in ipairs(BENILLA_MULTIBARS) do
MultiActionBar_UpdateGrid(bar.frame, 1)
end
end
function MultiActionBar_HideAllGrids()
for _, bar in ipairs(BENILLA_MULTIBARS) do
MultiActionBar_UpdateGrid(bar.frame)
end
end
-- The option's apply: re-derive the grid from ALWAYS_SHOW_MULTIBARS. Run on every write of
-- that global (the row's applyFunc) and once at startup over the restored saved value —
-- the reference does the same restore by hand in its VARIABLES_LOADED ladder
-- (UIOptionsFrame.lua l.217-220); ours is OptionsFrame_ApplySavedSettings' walk.
--
-- Both spellings tested because the global is published and the two stores disagree: our
-- options row writes the STRING "1", while an addon setting it writes the number (zBar
-- tests both for exactly this reason, zActionButton.lua:61).
--
-- The `held` latch is ours and it is what makes this option a WELL-BEHAVED holder of the
-- count: one asker may hold at most one. Without it, re-applying an "off" that was already
-- off decrements every button again — so applying defaults while a payload is in hand would
-- close the wells that payload is holding open. The reference has exactly that flaw; the
-- latch also means the startup apply of the default "0" is a no-op instead of a decrement.
BENILLA_MULTIBAR_GRID_HELD = nil
function MultiActionBar_UpdateGridVisibility()
local want
if ALWAYS_SHOW_MULTIBARS == "1" or ALWAYS_SHOW_MULTIBARS == 1 then want = 1 end
if want == BENILLA_MULTIBAR_GRID_HELD then return end
BENILLA_MULTIBAR_GRID_HELD = want
if want then
MultiActionBar_ShowAllGrids()
return
end
MultiActionBar_HideAllGrids()
end
-- ── The write half of a bar toggle (the Options rows' setter) ───────────────────────────
-- The reference has no counterpart: its panel edits SHOW_MULTI_ACTIONBAR_n inline in each
-- checkbox's OnClick and batches ONE SetActionBarToggles into UIOptionsFrame_Save. Our
-- window commits on the click (0961's grammar, no Save button), so the whole edit — the
-- global, the bars, the packet — is this one function and the rows pass it a bar index.
--
-- `value` is the row's "1"/"0" STRING (OptionsRow_Set hands an API row its own string), and
-- the number/boolean spellings are tolerated because this is reachable from addon code too.
-- What is stored is 1 or nil and nothing else: that is what GetActionBarToggles returns and
-- what the corpus expects to read back (see the globals' own comment above).
--
-- **FOUR arguments.** Not five. The reference passes ALWAYS_SHOW_MULTIBARS as a fifth and
-- the binding drops it on the floor (`cmp esi,4` at 0x4e770e) — see the header. The whole
-- byte is re-sent every time because the packet carries the byte, not a delta.
function BenillaMultiBar_SetShown(bar, value)
local on
if value and value ~= "0" and value ~= 0 then on = 1 end
setglobal("SHOW_MULTI_ACTIONBAR_" .. bar, on)
MultiActionBar_Update()
SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2,
SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)
end
-- The MULTIACTIONBAR keybind pair — ref MultiActionBars.lua:24-45 MultiActionButtonDown/Up,
-- the same NORMAL→PUSHED debounce dance as the main bar's ActionButtonDown/Up
-- (ActionBar.xml — the ref's MacroFrame_SaveMacro hook is not modeled there either).
-- Driven by the binding dispatch (crate::bindings, Kind::EdgeUpDown — decision 1008).
--
-- `bar` IS THE WHOLE FRAME-NAME PREFIX, not a suffix (decision 1199). The reference resolves
-- `getglobal(bar.."Button"..id)` and its own Bindings.xml passes "MultiBarBottomLeft"; ours
-- used to pass "BottomLeft" and splice "MultiBar" on in here. Same arity, different MEANING
-- — the silent kind of wrong shape: zBar calls `MultiActionButtonDown("zBar"..barid, id)`
-- and under the old splice built the nonexistent frame name "MultiBarzBar1Button1". The
-- caller now names the bar in full, exactly as the reference's binding does, so a third-
-- party bar can address these at all. `onSelf` rides to UseAction like ActionButtonUp's.
function MultiActionButtonDown(bar, id)
local button = getglobal(bar .. "Button" .. id)
if ( button:GetButtonState() == "NORMAL" ) then
button:SetButtonState("PUSHED")
end
end
function MultiActionButtonUp(bar, id, onSelf)
local button = getglobal(bar .. "Button" .. id)
if ( button:GetButtonState() == "PUSHED" ) then
button:SetButtonState("NORMAL")
UseAction(BenillaActionButton_Action(button), 0, onSelf)
-- Ref MultiActionButtonUp:39-43: the checked ring reflects the use immediately.
BenillaActionButton_UpdateState(button)
end
end
]]></Script>
<!-- frameStrata HIGH on both bottom bars is the ref's own (`MultiActionBars.xml` l.36 and l.151,
HorizontalMultiBar1/2). They share the bottom band with the bag windows, which are MEDIUM on
both sides by design — and both sides carry deep nested button trees, so co-tenanting one
stratum is exactly the level-outranks-insertion collision decision 0597 fixed for the party
frame. HIGH is what keeps the bars above an open bag.
`hidden="true"` on ALL FOUR bars since 1500 — the reference's own posture for every one of
them (its HorizontalMultiBar1/2 templates carry it too, l.36/l.151), and the shipped state
a character with a zero toggle byte lands in. MultiActionBar_Update is the only thing that
opens one. -->
<Frame name="MultiBarBottomLeft" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="500" y="38"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ActionButton1" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="17"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarBottomLeftButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
<!-- HIGH and hidden, same as BottomLeft above (ref `MultiActionBars.xml` l.151). -->
<Frame name="MultiBarBottomRight" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="500" y="38"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="MultiBarBottomLeft" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarBottomRightButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
<!-- **The two VERTICAL bars — now the same kind of thing as the two above them.**
They were scoped out first (a bar nobody asked for should not exist), then 1219 built them
as real frames that shipped unconditionally hidden — because the reference does not choose
between "shown" and "absent": `MultiActionBars.xml` l.515/523 instantiates BOTH as real
`parent="UIParent"` frames whose templates (VerticalMultiBar3/4, l.266/381) carry
`hidden="true"`, and they stay hidden until SHOW_MULTI_ACTIONBAR_3/4 says otherwise.
What 1219 bought, and still buys, is that Atlas, Bartender2 and FuBartender2 stopped DYING
at session start on `MultiBarLeft` — Atlas at `Atlas.lua:387`
(`MultiBarLeft:SetFrameStrata("MEDIUM")`), Bartender2 at `Bartender2.lua:74`
(`MultiBarLeft:ClearAllPoints()`). Neither shows the bar; both only need it to be there.
Five more addons index `MultiBarRight` (decision 1218's corrected ranking).
What 1500 changed is only the last sentence of that story: "hidden, and nothing can show
it" has become "hidden, until the player ticks its row" — SHOW_MULTI_ACTIONBAR_3/4 and the
Options page's two Right ActionBar rows. `MultiBarLeft` still needs `MultiBarRight` under
it (the header's conjunction), which is why its row is a dependent child of bar 3's.
Pages from ref-ActionButton.lua l.8-9: LEFT_ACTIONBAR_PAGE = 4 (actions 37..48),
RIGHT_ACTIONBAR_PAGE = 3 (actions 25..36). Geometry from ref-MultiActionBars.xml: a 38x500
frame, button 1 at TOPRIGHT, each next TOP-to-BOTTOM of the previous at -6. -->
<Frame name="MultiBarRight" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="38" y="500"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-7" y="98"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarRightButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton2" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton3" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton4" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton5" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton6" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton7" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton8" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton9" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton10" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton11" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
<Frame name="MultiBarLeft" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="38" y="500"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="MultiBarRight" relativePoint="TOPLEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarLeftButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton2" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton3" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton4" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton5" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton6" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton7" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton8" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton9" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton10" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton11" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
</Ui>
File diff suppressed because it is too large Load Diff
@@ -1,39 +0,0 @@
<Ui>
<!--
WHAT IS LEFT OF THIS FILE, AND WHY IT STILL EXISTS (decision 1841).
The four templates this file used to build — `OptionFrameBoxTemplate`,
`OptionsButtonTemplate`, `OptionsCheckButtonTemplate` and `OptionsSliderTemplate` — now come
off the player's chain out of `Interface\FrameXML\OptionsFrameTemplates.xml`, which the
manifest lists directly above this entry. Our transcription of them is gone.
**The same basename appears twice in benilla.toc on purpose.** A bare name is a file we
ship, a path is the reference's own (the manifest's rule since 1751), so the two entries are
unambiguous to the loader even though they read alike to a person. This one loads second and
adds exactly one name the chain's file does not have.
THAT NAME IS `UIOptionsCheckButtonTemplate`, and its home in the reference is
`UIOptionsFrame.xml` — a file we do not build at all, because benilla's own options window
is `OptionsFrame.xml`, a different design. So the template has no chain file to arrive from
and would simply vanish if this file did.
It is not ours and not invented: it is in `reference/1.12-globals.tsv`, and the corpus names
it at 8 `CreateFrame` call sites — the nameplate addons build their whole options panel out
of it. `panel_template_tests.rs` is the driver that says so.
WHEN THIS FILE GOES: the day `UIOptionsFrame.xml` migrates. The template comes from there,
this file has nothing left, and the manifest loses a line.
WHAT THE SWAP GAVE BACK. Our version factored the two widgets' hover into one
`BenillaOptionsWidget_ShowTooltip` helper, on the reasoning that "two copies of one
behaviour is how they end up disagreeing". The reference open-codes it inline in each
template's `<OnEnter>`, identically, and that is what runs now — the helper is gone with the
rest, and nothing else called it.
-->
<!-- ref UIOptionsFrame.xml l.6-10: a 26x26 override of the chain's own
`OptionsCheckButtonTemplate`, which it inherits from. -->
<CheckButton name="UIOptionsCheckButtonTemplate" inherits="OptionsCheckButtonTemplate" virtual="true">
<Size><AbsDimension x="26" y="26"/></Size>
</CheckButton>
</Ui>
@@ -1,558 +0,0 @@
<Ui>
<!--
The pet action bar (ref PetActionBarFrame.xml/.lua, extracted from patch.MPQ; decision
0982). Hunter, warlock, and every possessed/charmed unit: ten buttons over the ten packed
words the server sends in SMSG_PET_SPELLS, driven by `crate::ui_pet`'s feed through the
engine's pet seam (GetPetActionInfo & co.).
THE THING TO KNOW ABOUT THIS BAR: the server owns its CONTENTS (all ten slots arrive in
one SMSG_PET_SPELLS) but the CLIENT owns its lit state. A press repaints immediately and
is never confirmed, because the server does not answer these packets at all — the real
client writes its own state before the send (decision 0988). So a button whose ring does
not light on click is BROKEN, not patient.
The ten slots are not a layout. `GetPetActionInfo` reports each slot's own class off its
type byte, and the familiar 3 commands / 4 spells / 3 reactions arrangement is just what
vmangos's CharmInfo::InitPetActionBar happens to write; a charmed creature arrives with a
different mix through the same ten words. Nothing below indexes by position.
Token slots (the commands and reactions) return the NAMES OF GLOBALS rather than values,
which is the reference's own convention (`PetActionBarFrame.lua:98-104`): the name keys are
GlobalStrings' (PET_ACTION_ATTACK, PET_MODE_DEFENSIVE, …), and the texture globals are
declared below. Both halves of that mapping are a contract we do not get to choose — the
names are what `ui_pet/bar.rs` hands back and the icons are real files in the install — so
the specification for them is `PetActionBarFrame.lua:6-12`; see the declaration's own note
for what IS ours about the layout.
Geometry QUOTED from the ref (PetActionBarFrame.xml): frame 509x43; the two UI-PetBar shelf
strips (256x44 at TOPLEFT with TexCoords 0..1 / 0.015625..0.359375, then 184x44 to its
right with 0..0.71875 / 0.375..0.71875); buttons 30x30, button1 at frame BOTTOMLEFT +(36,2),
chained +8 (with the ref's own +7 between 6 and 7); the 54x54 UI-Quickslot2 ring CENTER
(0,-1). The bar rides `MainMenuBar` BOTTOMLEFT — our MainMenuBar, the same 1024x53
frame at screen BOTTOM the ref anchors to — at the managed PETACTIONBAR_YPOS (UIParent.xml
declares the row already).
Ref divergences, each deliberate:
- ONE refresh event. The app diffs the whole pushed bar and fires PET_BAR_UPDATE on any
change, so the ref's PET_BAR_UPDATE / PET_BAR_UPDATE_COOLDOWN / UNIT_PET / the
UNIT_FLAGS+UNIT_AURA pair it filters for arg1=="pet" collapse into one — the same
collapse StanceBar.xml makes, for the same reason.
- The drag is ONE verb. PickupPetAction serves shift-click, OnDragStart and OnReceiveDrag
alike (decision 1010) — that is the reference's own shape, not a collapse of ours: the
binding itself forks on whether the cursor is already carrying a pet payload, so there is
no "place" counterpart the way PlaceAction answers PickupAction on the main bar. Nothing
else can produce that payload, which is why the pet bar can be rearranged and never
populated: its contents are the server's.
- PET_BAR_SHOWGRID/HIDEGRID are live, and are the PET payload's alone. The engine derives
them off the same cursor transition that drives ACTIONBAR_SHOWGRID/HIDEGRID but on a
disjoint predicate, so a held spell lights the action bar's empty slots and a held pet
action lights this bar's — never both, because neither payload can land on the other bar.
- NO HotKey corner, and none is missing (1052). The ref's button inherits
ActionButtonTemplate's $parentHotKey and repaints it on UPDATE_BINDINGS — but
PetActionButton_SetHotkeys (l.311-319) writes the label only when
`bindingSuffix == this:GetID()`, a STRING against a NUMBER, which Lua never equates. So
the real 1.12 pet bar renders an empty hotkey on every slot, always, and the reporter's
own reference shot for B218 shows exactly that: CTRL-1..CTRL-5 bound in the window, no
glyph on any pet button. An empty corner is the faithful render; a filled one would be
the divergence. (Our own action bar's corners are real — ActionBar.xml — because the
ref's ActionButton_UpdateHotkeys has no such bug.)
- The autocast sparkle is TEXTURES, not the ref's <Model>. benilla has no Model widget
(the Cooldown and the bag ItemAnim took the same posture): model the mechanism, let our
own renderer draw it. The numbers below are the shipped model's own — see BENILLA_PET_
AUTOCAST_* .
-->
<Script><![CDATA[
NUM_PET_ACTION_SLOTS = 10
-- ── The token icons ───────────────────────────────────────────────────────────────────
--
-- These are GLOBALS on purpose, and the names are a contract, not a choice:
-- `GetPetActionInfo` hands back the NAME of one of them for a token slot and the paint
-- below resolves it with getglobal — the reference's own convention, and the one
-- `ui_pet/bar.rs` writes against. The icon paths are the reference's too; they name real
-- .blp files in the install, so there is nothing to choose there either.
--
-- What IS ours is the grouping. The bar's tokens come in two families and `ui_pet/bar.rs`
-- already separates them at the seam that names these — `command_token` (the orders you
-- give, keyed `PET_ACTION_*`) and `reaction_token` (the stance the pet holds, keyed
-- `PET_MODE_*`) — so they are laid out in those two families, in that file's own order,
-- and the two halves of the mapping read side by side. The reference keeps them in one
-- undifferentiated block, which hides the split.
-- Commands — the four orders, in `ui_pet/bar.rs`'s `command_token` order.
PET_WAIT_TEXTURE = "Interface\\Icons\\Spell_Nature_TimeStop"
PET_FOLLOW_TEXTURE = "Interface\\Icons\\Ability_Tracking"
PET_ATTACK_TEXTURE = "Interface\\Icons\\Ability_GhoulFrenzy"
PET_DISMISS_TEXTURE = "Interface\\Icons\\Spell_Shadow_Teleport"
-- Reactions — the three stances, in `ui_pet/bar.rs`'s `reaction_token` order.
PET_PASSIVE_TEXTURE = "Interface\\Icons\\Ability_Seal"
PET_DEFENSIVE_TEXTURE = "Interface\\Icons\\Ability_Defend"
PET_AGGRESSIVE_TEXTURE = "Interface\\Icons\\Ability_Racial_BloodRage"
-- ── The autocast sparkle ──────────────────────────────────────────────────────────────
--
-- NATIVE since decision 1383 (B282). The reference's "autocast is on" mark is a <Model>
-- playing Interface\Buttons\UI-AutoCastButton.mdx — a widget OUTSIDE the FrameXML layout
-- tree — and our old emulation here (a per-button pool of ~92 OVERLAY textures SetPoint'ed
-- every frame from OnUpdate) made the most expensive possible use of the script layer:
-- every frame it dirtied the layout epoch and forced two whole-roster anchor solves,
-- +4.25 ms/frame for ONE autocasting button at a default roster, scaling with the whole
-- UI (B282, nazriel's ~11 ms under addons). What a button carries now is ONE hidden
-- marker texture, $parentShine, whose path is the `benilla:autocast-shine` token: the
-- extract's conversion records its rect/layer as a shine site and draws nothing, and
-- `autocast_shine::emit_shine` (benilla-app) animates the four comet trails there on the
-- UI quad append lane — zero per-frame script traffic, the reference's own architecture
-- on our substrate. The M2 derivation (bones, emitters, ramps, the 30.72-unit projection,
-- decision 1321's truncating millisecond clock) moved whole into that module's header;
-- this file's only job is showing the marker exactly while autocast is enabled.
-- ── The buttons ───────────────────────────────────────────────────────────────────────
function BenillaPetActionButton_OnLoad(button, index)
-- Ref PetActionButton_OnLoad l.234-235. The right-click registration is load-bearing,
-- not boilerplate: it is the whole autocast toggle, and without it a right click is
-- simply never delivered.
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
-- The drag trio (decision 1010). Both buttons drag, ref-verbatim
-- (PetActionButton_OnLoad l.234) — and unlike the action bar's, ALL THREE gestures here
-- reach the same verb: the reference's OnClick-under-shift, OnDragStart and
-- OnReceiveDrag all call PickupPetAction, which forks on whether the cursor is already
-- carrying. Both drag ends honour LOCK_ACTIONBAR since 1136 — the same uvar the main bar
-- declares (ActionBar.xml), which is the ref's own arrangement too: PetActionBarFrame.lua
-- reads a global UIOptionsFrame owns.
button:RegisterForDrag("LeftButton", "RightButton")
button.index = index
button.icon = getglobal(button:GetName() .. "Icon")
button.cooldown = getglobal(button:GetName() .. "Cooldown")
button.autocastable = getglobal(button:GetName() .. "AutoCastable")
button.shine = getglobal(button:GetName() .. "Shine")
end
-- Ref PetActionButton_OnClick. The CheckButton widget toggles itself BEFORE OnClick fires,
-- and the reference immediately clears that (`this:SetChecked(0)`) — nothing about this bar
-- is locally latched, because the server owns every bit of it. Left = run the slot, unless
-- the pet is already on the attack, in which case it calls off; right = flip autocast.
function PetActionButton_OnClick(mouseButton)
local button = this
button:SetChecked(0)
-- Shift outranks the button fork entirely (ref l.253-255): a shift-click on the pet bar
-- is a pick-up whichever mouse button it came from, and it never toggles autocast.
if IsShiftKeyDown() then
PickupPetAction(button.index)
PetActionBar_Update()
elseif mouseButton == "RightButton" then
TogglePetAutocast(button.index)
elseif IsPetAttackActive(button.index) then
PetStopAttack()
else
CastPetAction(button.index)
end
end
-- The KEYBIND pair (decision 1052) — ref PetActionButtonDown/Up l.218-231, which is where
-- the BONUSACTIONBUTTONn bindings land: their bodies call BonusActionButtonDown/Up, and
-- those are one-liners onto these (BonusActionBarFrame.lua:106-112). The ref's own
-- runOnUp shape: DOWN only shows the pushed art, UP fires.
--
-- Note what the key path does NOT do — it is a bare CastPetAction, with none of the click
-- path's forks: no shift pick-up, no right-button autocast, and no IsPetAttackActive
-- call-off. That is the reference's split, not a simplification of ours: the fork lives in
-- PetActionButton_OnClick, which a key press never reaches.
--
-- On a PET bar none of that is visible, because the call-off fork is DEAD CODE there:
-- IsPetAttackActive needs the [0xb714b0] latch, and `0x4bd420` raises it only when the
-- bar's unit is the one you are POSSESSING (ui_pet.rs's `possessing` carries the carve —
-- UNIT_FLAG_POSSESSED, never a hunter's or warlock's pet). A pet's Attack button can never
-- latch lit, so click and key both simply order the attack. The split is visible on a
-- POSSESS bar (Mind Control, Eye of Kilrogg, Eyes of the Beast): there a click sends
-- PetStopAttack and CTRL-1 re-issues the order instead.
function PetActionButtonDown(id)
local button = getglobal("PetActionButton" .. id)
if ( button:GetButtonState() == "NORMAL" ) then
button:SetButtonState("PUSHED")
end
end
function PetActionButtonUp(id)
local button = getglobal("PetActionButton" .. id)
if ( button:GetButtonState() == "PUSHED" ) then
button:SetButtonState("NORMAL")
CastPetAction(id)
end
end
-- Both drag ends are the SAME verb (ref l.269-283, byte-verified at `0x4be180`): with an
-- empty cursor it picks the slot up, with a pet payload already held it drops onto this
-- slot. There is no separate "place" binding on this bar, which is also why a spell or an
-- item can never land here — nothing but a pet slot produces the payload it accepts.
--
-- Both also honour the lock (the ref wraps each body in `if LOCK_ACTIONBAR ~= "1" then …`).
-- The shift-click in OnClick above is unguarded on this bar too, exactly as the reference
-- leaves it (l.253-255): the lock stops the drag, shift-click stays the way through.
function PetActionButton_OnDragStart()
local button = this
if LOCK_ACTIONBAR == "1" then return end
PickupPetAction(button.index)
PetActionBar_Update()
end
function PetActionButton_OnReceiveDrag()
local button = this
if LOCK_ACTIONBAR == "1" then return end
PickupPetAction(button.index)
PetActionBar_Update()
end
-- Ref PetActionButton_OnEnter (PetActionBarFrame.lua l.285-305). ONE predicate,
-- `isToken or UberTooltips == "0"`, decides two things at once:
--
-- * WHICH TOOLTIP. A token slot has no spell to look up, so its tooltip is built here out
-- of the two strings the info call already returned — and the BINDING (B230). Anything
-- else is a real pet SPELL and goes through the engine's tooltip channel, which appends
-- nothing. So the parenthesised key belongs to Attack/Follow/Stay and the reactions, and
-- NOT to the pet's own spells: putting it on every slot would be the wrong fix.
-- * WHERE IT SITS. Only the UberTooltips-off leg moves the tooltip beside the button
-- (ANCHOR_RIGHT); a token with the CVar on, and every spell, take the default corner.
-- The CVar registers "1" (cvars.rs carries the byte-read), so the off leg is the
-- player's to reach and not what ships.
--
-- The suffix is the ref's own expression and it is UNCONDITIONAL — no `if the key exists`
-- guard, unlike MicroButtonTooltipText's version of the same idea (MicroMenu.xml). That is
-- visible, not academic: GetBindingText answers "" for a nil key (UIParent.xml, transcribed
-- from the ref's own l.1819-1821), so 1.12 really does render "Attack ()" on a token slot
-- whose row is unbound. Stock, none are: BONUSACTIONBUTTON1-10 default to CTRL-1..CTRL-0
-- (bindings/commands.rs, byte-real from the install's bindings-cache.wtf).
--
-- Those same ten rows are the binding B218 found the CORNER hotkey glyph can never show
-- (the header's ref-divergence list has the `bindingSuffix == this:GetID()` string-vs-number
-- reason). Corner empty, tooltip parenthesised — both faithful, at once.
function PetActionButton_OnEnter()
local button = this
if not GameTooltip then return end -- engine tests load file subsets
if not button.tooltipName then return end
local uber = GetCVar("UberTooltips")
if button.isToken or uber == "0" then
if uber == "0" then
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
else
GameTooltip_SetDefaultAnchor(GameTooltip, button)
end
local key = GetBindingText(GetBindingKey("BONUSACTIONBUTTON" .. button.index), "KEY_")
GameTooltip:SetText(button.tooltipName .. NORMAL_FONT_COLOR_CODE .. " (" .. key .. ")"
.. FONT_COLOR_CODE_CLOSE, 1.0, 1.0, 1.0)
if button.tooltipSubtext then
GameTooltip:AddLine(button.tooltipSubtext, "", 0.5, 0.5, 0.5)
end
GameTooltip:Show()
else
GameTooltip_SetDefaultAnchor(GameTooltip, button)
GameTooltip:SetPetAction(button.index)
end
end
function PetActionButton_OnLeave()
local button = this
if GameTooltip then GameTooltip:Hide() end
end
-- ── The bar ───────────────────────────────────────────────────────────────────────────
-- Ref PetActionBar_Update + PetActionBar_UpdateCooldowns, fused (one refresh event, see the
-- header). Every branch below is the reference's, including the two that are easy to lose:
-- an unnamed slot HIDES its button, and a slot with no texture swaps the button's ring from
-- the filled UI-Quickslot2 to the empty UI-Quickslot.
function PetActionBar_Update()
local usable = GetPetActionsUsable()
for i = 1, NUM_PET_ACTION_SLOTS do
local button = getglobal("PetActionButton" .. i)
local name, subtext, texture, isToken, isActive, autoCastAllowed, autoCastEnabled = GetPetActionInfo(i)
button.isToken = isToken
button.tooltipSubtext = subtext
if isToken then
-- The token convention: what came back are the NAMES of globals.
button.icon:SetTexture(getglobal(texture))
button.tooltipName = getglobal(name)
else
button.icon:SetTexture(texture)
button.tooltipName = name
end
if isActive then button:SetChecked(1) else button:SetChecked(0) end
if autoCastAllowed then button.autocastable:Show() else button.autocastable:Hide() end
-- The shine marker (decision 1383, header above): showing it IS the whole
-- enable — the native producer draws the trails wherever a shown marker sits.
if autoCastEnabled then button.shine:Show() else button.shine:Hide() end
-- An unnamed slot hides — UNLESS the drop grid is up, which is the whole point of
-- the grid: while a pet action rides the cursor every slot shows, so you can see
-- where it may land (ref PetActionBar_Update l.122-128 + PetActionBar_ShowGrid).
if name or (PetActionBarFrame.showgrid or 0) > 0 then
button:Show()
else
button:Hide()
end
if texture then
SetDesaturation(button.icon, not usable and 1 or nil)
button.icon:Show()
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot2")
else
button.icon:Hide()
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot")
end
local start, duration, enable = GetPetActionCooldown(i)
CooldownFrame_SetTimer(button.cooldown, start, duration, enable)
end
end
function PetActionBar_OnLoad()
local frame = this
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("PET_BAR_UPDATE")
-- The drop grid (decision 1010). A counter rather than a flag, ref-verbatim
-- (PetActionBar_ShowGrid/_HideGrid): our engine derives the pair off one payload edge
-- so they strictly alternate, but the counter is what the reference keeps and it costs
-- nothing to keep it too.
frame:RegisterEvent("PET_BAR_SHOWGRID")
frame:RegisterEvent("PET_BAR_HIDEGRID")
frame.showgrid = 0
PetActionBar_OnEvent()
end
-- The bar exists exactly while PetHasActionBar() does. Its show/hide re-fires the managed
-- bottom-stack pass (UIParent.xml, decision 0272) — the pet term of that stack is what
-- lifts the cast bar and the chat frame clear of it, which is the ref's own
-- PetActionBarFrame.xml:205-210.
-- Declares nothing and reads the ambient `event`, exactly as the reference does
-- (PetActionBarFrame.lua:33 / .xml's bare `PetActionBar_OnEvent();`). PetActionBar_OnLoad's
-- own tail call therefore lands with `event` nil, which is the no-grid-edge path — the same
-- thing the old explicit `eventName` argument did when omitted.
function PetActionBar_OnEvent()
local frame = this
if event == "PET_BAR_SHOWGRID" then
frame.showgrid = frame.showgrid + 1
elseif event == "PET_BAR_HIDEGRID" and frame.showgrid > 0 then
frame.showgrid = frame.showgrid - 1
end
if PetHasActionBar() then
PetActionBar_Update()
frame:Show()
else
frame:Hide()
end
end
-- ── The pet menu's two dialogs (ref StaticPopup.lua l.738-748 + l.1069-1102 + l.365-374,
-- decision 1066) ────────────────────────────────────────────────────────────────────
-- They live here rather than with the rows that raise them (UnitPopup.xml) for the same
-- reason every other dialog in this codebase lives with its arc's frame file: registering
-- into StaticPopupDialogs is a LOAD-TIME dependency on UiPanels.xml's engine, and
-- UnitPopup.xml is a shared engine that eight isolation prefixes load without it. Raising
-- them is not a dependency — StaticPopup_Show resolves at click time.
--
-- ABANDON_PET is the plain confirm. RENAME_PET is a CHAIN of two, which is the reference's
-- own shape and not a flourish: the first collects the name, the second reads it back
-- ("Name your pet 'Bruce'?") before anything is sent — because a rename is one-shot, so a
-- typo is permanent in exactly the way the abandon is. That second Show happens while the
-- first dialog is still up, which is why the engine's two instances are both needed.
StaticPopupDialogs["ABANDON_PET"] = {
text = ABANDON_PET,
button1 = OKAY,
button2 = CANCEL,
OnAccept = function()
PetAbandon();
end,
timeout = 0,
exclusive = 1,
hideOnEscape = 1,
}
StaticPopupDialogs["RENAME_PET"] = {
text = PET_RENAME_LABEL,
button1 = ACCEPT,
button2 = CANCEL,
hasEditBox = 1,
-- The 12-letter cap is the reference's, and it is the client's only name check: the
-- server re-validates with CheckPetName and refuses with SMSG_PET_NAME_INVALID.
maxLetters = 12,
OnAccept = function()
-- `this` is the clicked button, so its parent is THIS dialog — never
-- StaticPopup1 by name, which would read the wrong box the moment this dialog
-- lands in the second instance (the chain below is exactly that case).
local text = getglobal(this:GetParent():GetName() .. "EditBox"):GetText()
local dialog = StaticPopup_Show("PETRENAMECONFIRM", text)
if dialog then
dialog.data = text
end
end,
EditBoxOnEnterPressed = function()
local text = getglobal(this:GetParent():GetName() .. "EditBox"):GetText()
local dialog = StaticPopup_Show("PETRENAMECONFIRM", text)
if dialog then
dialog.data = text
end
this:GetParent():Hide()
end,
OnShow = function()
getglobal(this:GetName() .. "EditBox"):SetFocus()
end,
timeout = 0,
exclusive = 1,
hideOnEscape = 1,
}
StaticPopupDialogs["PETRENAMECONFIRM"] = {
-- "Name your pet '%s'?" — the name arrives as text_arg1 AND as `data`; the first
-- fills the question, the second is what actually gets sent.
text = PET_RENAME_CONFIRMATION,
button1 = YES,
button2 = NO,
OnAccept = function(data)
PetRename(data);
end,
timeout = 0,
hideOnEscape = 1,
}
]]></Script>
<!--
One pet button (ref PetActionButtonTemplate: 30x30, inherits ActionButtonTemplate's art
set, plus the AutoCastable ring on OVERLAY at 58x58 CENTER). The Shine texture is the
ref's $parentAutoCast <Model> re-expressed as the native lane's marker (decision 1383,
the sparkle header above): its token path draws nothing itself — a shown marker is where
`autocast_shine` plays the trails, at exactly this OVERLAY layer's paint order.
-->
<CheckButton name="BenillaPetActionButtonTemplate" virtual="true">
<Size><AbsDimension x="30" y="30"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon"/>
</Layer>
<Layer level="OVERLAY">
<Texture name="$parentAutoCastable" file="Interface\Buttons\UI-AutoCastableOverlay" hidden="true">
<Size><AbsDimension x="58" y="58"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
<!-- The ref's $parentAutoCast <Model> here IS setAllPoints on the 30x30 button,
at scale="1.2" — the suffix carries that scale the way the ref's own XML
does (its sibling in SpellBookFrame.xml writes a different one). -->
<Texture name="$parentShine" file="benilla:autocast-shine:1.2" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- Ref PetActionButton_OnLoad sizes this 33x33 at CENTER (-2,-1) by hand rather than
taking the template's own rect — quoted. -->
<Cooldown name="$parentCooldown" hidden="true">
<Size><AbsDimension x="33" y="33"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-2" y="-1"/></Offset></Anchor>
</Anchors>
</Cooldown>
</Frames>
<NormalTexture name="$parentNormalTexture" file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="54" y="54"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="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"/>
<Scripts>
<OnClick>PetActionButton_OnClick(arg1)</OnClick>
<OnDragStart>PetActionButton_OnDragStart()</OnDragStart>
<OnReceiveDrag>PetActionButton_OnReceiveDrag()</OnReceiveDrag>
<OnEnter>PetActionButton_OnEnter()</OnEnter>
<OnLeave>PetActionButton_OnLeave()</OnLeave>
</Scripts>
</CheckButton>
<Frame name="PetActionBarFrame" enableMouse="true" parent="MainMenuBar" hidden="true">
<Size><AbsDimension x="509" y="43"/></Size>
<!-- This anchor is the LOAD-TIME value only: UIParent_ManageFramePositions owns the real
one and re-seats the frame on every pass (OnShow/OnHide below fire it), because the
Y is a managed var — the ref's own PETACTIONBAR_YPOS, 97 here and 97+43 with the
bottom-left multibar up. Leaving it seated at the base 97 is what drew the bar across
that bar's buttons (decision 0988). The X likewise: 36, or past the last stance
button when the two bars share this row.
The ref also slides the frame in over 0.09 s; we don't. The slide's start and end
anchors are the same two points, and a frame that only ever appears with a pet reads
the same arriving seated as it does arriving in 90 ms (the stance bar's own posture,
decision 0270). -->
<Anchors>
<Anchor point="TOPLEFT" relativeTo="MainMenuBar" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="36" y="97"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="SlidingActionBarTexture0" file="Interface\PetActionBar\UI-PetBar">
<Size><AbsDimension x="256" y="44"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0" right="1.0" top="0.015625" bottom="0.359375"/>
</Texture>
<Texture name="SlidingActionBarTexture1" file="Interface\PetActionBar\UI-PetBar">
<Size><AbsDimension x="184" y="44"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="SlidingActionBarTexture0" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="0.71875" top="0.375" bottom="0.71875"/>
</Texture>
</Layer>
</Layers>
<Frames>
<CheckButton name="PetActionButton1" inherits="BenillaPetActionButtonTemplate" id="1">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="36" y="2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 1)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="PetActionButton2" inherits="BenillaPetActionButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton1" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 2)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="PetActionButton3" inherits="BenillaPetActionButtonTemplate" id="3">
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton2" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 3)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="PetActionButton4" inherits="BenillaPetActionButtonTemplate" id="4">
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton3" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 4)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="PetActionButton5" inherits="BenillaPetActionButtonTemplate" id="5">
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton4" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 5)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="PetActionButton6" inherits="BenillaPetActionButtonTemplate" id="6">
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton5" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 6)</OnLoad></Scripts>
</CheckButton>
<!-- The ref's own +7 here, not +8 (PetActionBarFrame.xml l.158-166). -->
<CheckButton name="PetActionButton7" inherits="BenillaPetActionButtonTemplate" id="7">
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton6" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 7)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="PetActionButton8" inherits="BenillaPetActionButtonTemplate" id="8">
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton7" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 8)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="PetActionButton9" inherits="BenillaPetActionButtonTemplate" id="9">
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton8" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 9)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="PetActionButton10" inherits="BenillaPetActionButtonTemplate" id="10">
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton9" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 10)</OnLoad></Scripts>
</CheckButton>
</Frames>
<Scripts>
<OnLoad>PetActionBar_OnLoad()</OnLoad>
<OnEvent>PetActionBar_OnEvent()</OnEvent>
<OnShow>UIParent_ManageFramePositions()</OnShow>
<OnHide>UIParent_ManageFramePositions()</OnHide>
</Scripts>
</Frame>
</Ui>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,64 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The escort-quest confirm (decision 1733): one StaticPopup entry on our shared engine
(UiPanels.xml) plus its event driver. The patch-chain FrameXML is the SPECIFICATION —
StaticPopup.lua l.727-737 (the QUEST_ACCEPT entry) and UIParent.lua l.93 + l.353-356 (the
registration and the one-line arm) — and it fixes the dialog key, the field names and the
one verb by name, so those are reproduced exactly.
WHY THIS IS ITS OWN FILE, next to QuestFrame.xml and QuestLogFrame.xml rather than inside
either. It belongs to neither window. The confirm has no window at all — it is raised by
`crate::ui_quest_share` off `SMSG_QUEST_CONFIRM_ACCEPT`, exactly as DuelFrame.xml's two
dialogs are raised off the duel packets, and answering it sends a packet neither quest
window knows about. Putting it in QuestFrame.xml would say the questgiver panel owns it
(it does not — no panel is open when this arrives) and putting it in QuestLogFrame.xml
would say the log window does (it does not either: the log is closed for most players when
a party member starts an escort).
WHAT RAISES IT. `QUEST_FLAGS_PARTY_ACCEPT` (0x2) quests — escorts. When a party member
accepts one, the server asks every other eligible member whether they want it too
(vmangos `QuestHandler.cpp:172-193`). That is a DIFFERENT flow from the quest-log's *Share
Quest* button, which needs no dialog at all: a pushed quest arrives on the ordinary
questgiver detail panel (QuestFrame.xml) with the sharer's player guid where an NPC's
would be, and Accept/Decline there are the whole answer.
THE ONE ASYMMETRY, and it is the reference's, not ours: Yes calls ConfirmAcceptQuest() and
sends `CMSG_QUEST_CONFIRM_ACCEPT`; **No sends nothing at all**. There is no OnCancel below
because there is no packet for one — the server's pending-share latch is simply cleared by
the next thing that touches it. The same is true of ESC (hideOnEscape).
-->
<Script><![CDATA[
-- The shipped enUS GlobalStrings.lua values, quoted as data (l.3227, and the YES/NO pair).
-- First %s is the party member's name, second is the quest title — the argument order
-- QUEST_ACCEPT_CONFIRM fires with.
local QUEST_ACCEPT = "%s is starting %s\nWould you like to as well?"
StaticPopupDialogs["QUEST_ACCEPT"] = {
text = QUEST_ACCEPT,
button1 = "Yes", -- YES
button2 = "No", -- NO
OnAccept = function()
ConfirmAcceptQuest()
end,
timeout = 0,
exclusive = 1,
hideOnEscape = 1,
}
-- spec: UIParent.lua l.353-356 — one arm, both args straight through.
function BenillaQuestShareDriver_OnEvent()
if event == "QUEST_ACCEPT_CONFIRM" then
StaticPopup_Show("QUEST_ACCEPT", arg1, arg2)
end
end
]]></Script>
<Frame name="BenillaQuestShareDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>self:RegisterEvent("QUEST_ACCEPT_CONFIRM")</OnLoad>
<OnEvent>BenillaQuestShareDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
File diff suppressed because it is too large Load Diff
@@ -1,922 +0,0 @@
<Ui>
<!--
benilla's Reputation tab: our own build of the 1.12 `ReputationFrame`. The reference
FrameXML `ReputationFrame.{xml,lua}` is the SPECIFICATION throughout — what the page must
look like and do — and it is cited line by line at every site (cites read `ref l.N` into
`ReputationFrame.xml`, `ref-lua l.N` into `ReputationFrame.lua`). Every geometry number, art
path and TexCoord below is QUOTED from it, because those are the client's facts and there is
no other value they could take; the Lua is ours, written against that specification and onto
benilla's Era API — the twelve reputation globals of
`crates/benilla-ui/src/script/reputation.rs`, whose module doc is the display law this file
paints, over `crates/benilla-app/src/ui_reputation.rs`'s feed. Where a line still reads
exactly like the reference's it is because the frame names and the API are the
addon-visible contract and neither is ours to vary.
A CHILD PAGE of `CharacterFrame` (ref l.211: `parent="CharacterFrame"`, `id="3"`), shown and
hidden by `CharacterFrame_ShowSubFrame` — a SEPARATE top-level file, the SkillFrame.xml /
PetPaperDollFrame.xml shape, and it carries the reference's `parent="CharacterFrame"` for
real: the loader does read a cross-file `parent=`, resolving the name by global lookup at
load (decision 1734). That name must exist by the time this file loads — an unresolvable one
warns and falls back to the enclosing element rather than erroring — which the manifest order
gives it. The page is still pinned to `CharacterFrame`'s own TOPLEFT by NAME and still loaded
right after it so it paints on top (later file = higher z): that arrangement produces the
identical rect and is what the page was built and approved against, not a workaround for
something the engine cannot do.
`CharacterFrame.xml`'s `CharacterFrame_OnHide` hide-loop over
`CHARACTERFRAME_SUBFRAMES` is what closes it with the window; joining that table as
entry 3 is all this page has to do to get that.
THE TAB IS THE REFERENCE'S OWN SLOT 3 AGAIN. Until this page, `SkillFrame` sat at id 3 with
a comment saying it was 3rd only because Reputation did not exist; Skills is id 4 now, which
is the real client's number, and `PetTab_Update` (PetPaperDollFrame.xml) re-anchors
`CharacterFrameTab3` exactly as the reference's does — same tab, same page, no divergence
left to carry.
NO OWN PORTRAIT: the "General" quadrant art below carries the same portrait-ring cutout
every PaperDollInfoFrame page shares, so `CharacterFrame`'s portrait/name/level (drawn
underneath) shows through — the reference's own compositing, and SkillFrame.xml's note.
DEVIATIONS (each re-cited at its own site below):
1. THE WATCH BAR IS NOT IN THIS FILE. The reference declares `ReputationWatchBar` here
(ref l.869-1036) and defines `ReputationWatchBar_Update` / `MAX_PLAYER_LEVEL` /
`FACTION_BAR_COLORS` / `Show`/`HideWatchedReputationBarText` in `ReputationFrame.lua`;
benilla keeps all of that in `ActionBar.xml`, where the frame already lived, and that
file's own comment at the block carries the full justification. The short of it: the
watch bar is a MainMenuBar fixture, and EVERY frame its update touches
(`MainMenuExpBar`, `MainMenuBarMaxLevelBar`, `ExhaustionTick`, `MainMenuBarArtFrame`,
`MainMenuBar` itself) is declared in `ActionBar.xml`, ~120 manifest entries above this
one. This page reaches `ReputationWatchBar_Update()` by name at click time, which is
the direction that always resolves.
2. `GetText("FACTION_STANDING_LABEL"..standingID, gender)` (ref-lua l.65) is the client's
C-side localization lookup, which benilla does not ship (UIParent.xml's `SecondsToTime`
records the same departure). Its one load-bearing rule — take the `_FEMALE` twin when
gender is 3 — is transcribed into `BenillaReputation_StandingLabel` below.
3. THE OPTIMISTIC VERBS FIRE NO EVENT, so this file repaints where the reference lets
`UPDATE_FACTION` do it. `CollapseFactionHeader`/`ExpandFactionHeader` and
`SetFactionInactive`/`SetFactionActive` change what the list shows the instant they are
called (the fold pair hide or reveal a group; the inactive pair RE-PARENT the row under
the "Inactive" header and rebuild the tree — reputation.rs's own note at
`0x4d69b0`), and none of them raises an event here the way the client's C bindings do.
Both call sites below therefore end in `ReputationFrame_Update()`, exactly as the
reference's own at-war box already does (ref l.765) — SkillFrame.xml's header click has
the identical addition for the identical reason.
`SetWatchedFactionIndex` gets NO such addition, and that is the interesting half:
it is **not** optimistic (`0x4d6b60` — the watched slot is a server field with no
client mirror), so there is nothing new for a repaint to paint, and forcing one would
un-tick the box the player just ticked before the round trip could answer. The
reference calls only `ReputationWatchBar_Update()` there, and so do we.
4. THE SCROLL FRAME IS A `<ScrollFrame>` ON THE CHAIN'S FAUX KIT, and this note used to
say otherwise. It read "a plain `<Frame inherits=\"FauxScrollFrameTemplate\">` rather
than the reference's `<ScrollFrame>`" — true while the kit was ours. 1846 put
`FauxScrollFrameTemplate` on the chain, where it IS a `<ScrollFrame>`, so every one of
our instances became one too: a `<Frame>` inheriting it has no `SetScrollChild` and the
loader throws on the template's own `<ScrollChild>`. The two
loose trough textures the reference hangs beside the bar (ref l.572-600) are replaced
by the kit's shared `BenillaScrollTroughTemplate`; see the site for why the fit is
exact rather than approximate.
5. `enableMouse="true"` ADDED in three places (the bar template, the two label-hover
frames, the at-war indicator). In this engine only Button/CheckButton/EditBox/
ScrollingMessageFrame are mouse-enabled by construction (`widget/mod.rs`'s ctor
comment; UnitFrames.xml and CharacterFrame.xml both carry the same note), so a
StatusBar or a plain Frame with `<OnEnter>` needs the attribute or the handler silently
never fires. This REPRODUCES the reference's behaviour rather than departing from it.
6. TWO PIECES OF THE REFERENCE'S OWN DEAD WEIGHT DROPPED (the TradeSkillFrame.xml
precedent): `ReputationFrame_Update`'s `rightBarTexture` local (ref-lua l.36/71),
assigned and never read even in the reference, and the `text="Revered"` placeholder on
`$parentFactionStanding` (ref l.102), which only ever shows on a row that has not been
painted yet — and rows are hidden until they are.
7. A WHEEL HANDLER, where the reference has a bare `return;` (ref l.864-866). Its scroll
frame is a real `<ScrollFrame>` and takes the wheel itself; ours is a plain Frame, so
the page drives the faux kit. No wheel-catcher Button is needed (SkillFrame.xml ships
one): this page is mouse-enabled over its whole rect, so a spin in a gap between rows
lands on the page itself, and a spin on a row bubbles up the parent chain to it — and
a full-page catcher is precisely what ate SkillFrame's tab row in the 2026-07-17
report.
8. NO `LockHighlight` (SkillFrame.xml deviation 7): not needed here — the reference's own
selection glow is the pair of `$parentHighlight1`/`2` textures on the bar, shown and
hidden by hand, which is already the affordance this engine has.
-->
<Script><![CDATA[
-- ── Constants (ref-lua l.1-2) ───────────────────────────────────────────────────────────
-- Globals, as the reference declares them: NUM_FACTIONS_DISPLAYED is the row-slot count the
-- XML below instantiates, REPUTATIONFRAME_FACTIONHEIGHT the faux kit's pixel value step.
NUM_FACTIONS_DISPLAYED = 15
REPUTATIONFRAME_FACTIONHEIGHT = 26
-- ── GlobalStrings (ref-GlobalStrings.lua, cited per line) ───────────────────────────────
-- The `X = X or "..."` idiom is this codebase's convention: the app runs the real localized
-- GlobalStrings.lua off the patch chain BEFORE any window file, so the installed locale
-- wins and these copies only fill in for the test harnesses, which load no chain.
FACTION = FACTION or "Faction" -- :1938
STANDING = STANDING or "Standing" -- :4193
AT_WAR = AT_WAR or "At War" -- :77
MOVE_TO_INACTIVE = MOVE_TO_INACTIVE or "Move to Inactive" -- :2706
SHOW_FACTION_ON_MAINSCREEN = SHOW_FACTION_ON_MAINSCREEN or "Show as Experience Bar" -- :3438
REPUTATION_FACTION_DESCRIPTION = REPUTATION_FACTION_DESCRIPTION or
"These are the factions that you have interacted with." -- :3330
REPUTATION_STANDING_DESCRIPTION = REPUTATION_STANDING_DESCRIPTION or
"The further to the right your standing bar is the closer you are to improving your reputation to the next tier with that faction. The Standing Levels in order are Hated, Hostile, Unfriendly, Neutral, Friendly, Honored, Revered and Exalted." -- :3331
REPUTATION_STATUS_AT_WAR = REPUTATION_STATUS_AT_WAR or
"You are currently at war with this group." -- :3333
REPUTATION_AT_WAR_DESCRIPTION = REPUTATION_AT_WAR_DESCRIPTION or
"This determines how you will interact with members of this faction. If you have at war checked then your default behavior will be to attack them. If you do not have it checked you will not be able to attack them. " -- :3329
-- The two SYNTHETIC header names. The engine reads these globals out of the VM itself
-- (reputation.rs `synthetic_header`), which is the only reason either string has a consumer
-- at all: GlobalStrings.lua defines both and no reference FrameXML file anywhere reads
-- them, because the real client builds the list C-side. `FACTION_OTHER` heads the
-- parentless bucket (key 0); `FACTION_INACTIVE` heads the bucket a faction moves to when
-- the popup's "Move to Inactive" box is ticked (key -1), which the engine folds by default
-- on every rebuild, exactly as the client does.
FACTION_OTHER = FACTION_OTHER or "Other" -- :1943
FACTION_INACTIVE = FACTION_INACTIVE or "Inactive" -- :1942
-- The eight standing names, and their `_FEMALE` twins (:1947-1962). enUS spells both the
-- same; a localized client's own GlobalStrings.lua overwrites the pair and the gender rule
-- in BenillaReputation_StandingLabel starts mattering.
FACTION_STANDING_LABEL1 = FACTION_STANDING_LABEL1 or "Hated"
FACTION_STANDING_LABEL2 = FACTION_STANDING_LABEL2 or "Hostile"
FACTION_STANDING_LABEL3 = FACTION_STANDING_LABEL3 or "Unfriendly"
FACTION_STANDING_LABEL4 = FACTION_STANDING_LABEL4 or "Neutral"
FACTION_STANDING_LABEL5 = FACTION_STANDING_LABEL5 or "Friendly"
FACTION_STANDING_LABEL6 = FACTION_STANDING_LABEL6 or "Honored"
FACTION_STANDING_LABEL7 = FACTION_STANDING_LABEL7 or "Revered"
FACTION_STANDING_LABEL8 = FACTION_STANDING_LABEL8 or "Exalted"
FACTION_STANDING_LABEL1_FEMALE = FACTION_STANDING_LABEL1_FEMALE or "Hated"
FACTION_STANDING_LABEL2_FEMALE = FACTION_STANDING_LABEL2_FEMALE or "Hostile"
FACTION_STANDING_LABEL3_FEMALE = FACTION_STANDING_LABEL3_FEMALE or "Unfriendly"
FACTION_STANDING_LABEL4_FEMALE = FACTION_STANDING_LABEL4_FEMALE or "Neutral"
FACTION_STANDING_LABEL5_FEMALE = FACTION_STANDING_LABEL5_FEMALE or "Friendly"
FACTION_STANDING_LABEL6_FEMALE = FACTION_STANDING_LABEL6_FEMALE or "Honored"
FACTION_STANDING_LABEL7_FEMALE = FACTION_STANDING_LABEL7_FEMALE or "Revered"
FACTION_STANDING_LABEL8_FEMALE = FACTION_STANDING_LABEL8_FEMALE or "Exalted"
-- `FACTION_BAR_COLORS` (ref-lua l.3-12) is NOT declared here: it lives in ActionBar.xml
-- beside `ReputationWatchBar_Update`, its other consumer, because that file must be able to
-- index it with no reputation file loaded and this one cannot be loaded without it (this
-- file's header comment, deviation 1). ActionBar.xml is manifest entry 94 against this
-- file's 213, so the table is always there by the time a row paints.
-- The standing name for `standingID` (1..8) at the player's gender — the reference's
-- `GetText("FACTION_STANDING_LABEL"..standingID, gender)` (ref-lua l.65) with its C-side
-- lookup collapsed (this file's header comment, deviation 2). `UnitSex` answers 2 for male
-- and 3 for female, which is the value the reference passes straight through.
function BenillaReputation_StandingLabel(standingID, gender)
if gender == 3 then
local female = getglobal("FACTION_STANDING_LABEL" .. standingID .. "_FEMALE")
if female then
return female
end
end
return getglobal("FACTION_STANDING_LABEL" .. standingID) or ""
end
-- A checkbox takes the client's 1/nil rather than a boolean, and every box on this page is
-- driven straight off a flag out of the faction tuple — so one verb, not an if/else pair
-- per box.
function BenillaReputation_SetBoxChecked(box, on)
box:SetChecked(on and 1 or nil)
end
-- The selection glow is a PAIR of textures, because the row's frame art is two pieces
-- (`$parentHighlight1` reaches back over the name, `2` caps the bar's right end). They are
-- always raised and dropped together, from three places: the update below, and the row's
-- own OnEnter/OnLeave.
function BenillaReputationBar_SetGlow(barName, lit)
local left, right = getglobal(barName .. "Highlight1"), getglobal(barName .. "Highlight2")
if lit then
left:Show()
right:Show()
else
left:Hide()
right:Hide()
end
end
-- Fill the detail popup for the row at `factionIndex` (ref-lua l.106-133 is the
-- specification). Called only when the popup is up AND this row is the selected one.
-- Everything it touches is a `ReputationDetail*` widget, which is the addon-visible name
-- and so not ours to vary; what IS ours is that the three tick states are one verb and the
-- at-war label's colour is written once instead of once per arm.
function BenillaReputationDetail_Fill(factionIndex, name, description, atWarWith, canToggleAtWar, isWatched)
ReputationDetailFactionName:SetText(name)
ReputationDetailFactionDescription:SetText(description)
BenillaReputation_SetBoxChecked(ReputationDetailAtWarCheckBox, atWarWith)
-- A peace-forced faction cannot be fought, so its box is dead and the label greys off
-- the angry red to say so.
local label = GRAY_FONT_COLOR
if canToggleAtWar then
label = RED_FONT_COLOR
ReputationDetailAtWarCheckBox:Enable()
else
ReputationDetailAtWarCheckBox:Disable()
end
ReputationDetailAtWarCheckBoxText:SetTextColor(label.r, label.g, label.b)
BenillaReputation_SetBoxChecked(ReputationDetailInactiveCheckBox, IsFactionInactive(factionIndex))
BenillaReputation_SetBoxChecked(ReputationDetailMainScreenCheckBox, isWatched)
end
-- A row SLOT is a pair of twins on one line — the header BUTTON `ReputationHeader<i>` and
-- the faction STATUSBAR `ReputationBar<i>` with all its furniture — of which exactly one is
-- ever up. The two verbs below cover every state a slot can be in (a header, a faction, or
-- nothing at all), and each raises the twin it wants and drops the other, so no caller has
-- to remember the pairing.
-- Slot `i` has nothing to hold: both twins go dark (ref-lua l.141-143).
function BenillaReputationSlot_Clear(i)
getglobal("ReputationHeader" .. i):Hide()
getglobal("ReputationBar" .. i):Hide()
end
-- Slot `i` holds the visible row at `factionIndex`. Split out of the update loop so that
-- loop is about the SCROLL WINDOW and this is about ONE ROW; the eleven-wide faction tuple
-- then has exactly one reader in the file. ref-lua l.51-139 is the specification, and the
-- reference's own unread `rightBarTexture` local is not carried (this file's header
-- comment, deviation 6).
function BenillaReputationSlot_Paint(i, factionIndex, gender)
local bar = getglobal("ReputationBar" .. i)
local header = getglobal("ReputationHeader" .. i)
local check = getglobal("ReputationBar" .. i .. "Check")
local name, description, standingID, barMin, barMax, barValue, atWarWith,
canToggleAtWar, isHeader, isCollapsed, isWatched = GetFactionInfo(factionIndex)
if isHeader then
-- A GROUP HEADER: the group's name, the fold icon its state calls for, and the
-- visible index a click on it folds by. The icon says what the click WILL do — an
-- open group offers the minus that shuts it, a shut one the plus that opens it.
local foldIcon = "Interface\\Buttons\\UI-MinusButton-Up"
if isCollapsed then
foldIcon = "Interface\\Buttons\\UI-PlusButton-Up"
end
header.index = factionIndex
header.isCollapsed = isCollapsed
header:SetText(name)
header:SetNormalTexture(foldIcon)
header:Show()
bar:Hide()
check:Hide()
return
end
-- A FACTION: the name and the standing word it currently wears.
local standingText = BenillaReputation_StandingLabel(standingID, gender)
local factionName = getglobal("ReputationBar" .. i .. "FactionName")
factionName:SetText(name)
getglobal("ReputationBar" .. i .. "FactionStanding"):SetText(standingText)
-- The crossed-swords pennant off the row's right end.
local atWarIndicator = getglobal("ReputationBar" .. i .. "AtWarCheck")
if atWarWith then
atWarIndicator:Show()
else
atWarIndicator:Hide()
end
-- The engine hands the rank window over ABSOLUTE for exactly this reason:
-- reputation.rs's module doc names the reference's own normalization (ref-lua l.79-82)
-- as the contract the tuple is written against. The bar's range is the window's WIDTH,
-- its value the distance into it, and the hover tooltip is the same pair spelled out.
local rankWidth = barMax - barMin
local progress = barValue - barMin
bar.id = factionIndex
bar.standingText = standingText
bar.tooltip = HIGHLIGHT_FONT_COLOR_CODE .. " " .. progress .. " / " .. rankWidth .. FONT_COLOR_CODE_CLOSE
bar:SetMinMaxValues(0, rankWidth)
bar:SetValue(progress)
-- UNGUARDED, deliberately, exactly as the reference has it (ref-lua l.89).
-- `GetFactionInfo` is eleven wide on EVERY path including a miss, and its miss puts the
-- client's own `1` in the standingID slot for precisely this index (reputation.rs
-- `unknown_faction`) — so the caller's `factionIndex <= numFactions` gate is belt, and
-- that `1` is braces. Adding a nil-check here would be inventing a branch the client
-- does not have and cannot reach.
local color = FACTION_BAR_COLORS[standingID]
bar:SetStatusBarColor(color.r, color.g, color.b)
bar:SetID(factionIndex)
bar:Show()
header:Hide()
-- The watched checkmark rides the END of the name string, so the name box gives up 10
-- of its 110px to make room for it (ref-lua l.95-103).
if isWatched then
check:Show()
factionName:SetWidth(100)
check:SetPoint("LEFT", factionName, "LEFT", factionName:GetStringWidth(), 0)
else
check:Hide()
factionName:SetWidth(110)
end
-- The selected row keeps its glow with the mouse away, and is the one that fills the
-- popup while the popup is open (ref-lua l.105-139).
local selected = factionIndex == GetSelectedFaction()
if selected and ReputationDetailFrame:IsShown() then
BenillaReputationDetail_Fill(factionIndex, name, description, atWarWith,
canToggleAtWar, isWatched)
end
BenillaReputationBar_SetGlow("ReputationBar" .. i, selected)
end
-- The list page (ref-lua ReputationFrame_Update, l.32-149 is the specification): fit the
-- scroll window to the list, then bind each of the fixed slots to the row the offset puts
-- under it, on the house faux-scroll kit rather than the reference's real ScrollFrame
-- (this file's header comment, deviation 4).
function ReputationFrame_Update()
local numFactions = GetNumFactions()
local scrollFrame = getglobal("ReputationListScrollFrame")
-- ref-lua l.39-41. The reset is a no-op in every reachable case — FauxScrollFrame_Update
-- has already clamped `frame.offset` to 0 when the list fits and seated the bar at
-- `offset * valueStep`, so this SetValue writes the value the Slider already holds and
-- its change-gate (decision 0250) drops it before it could re-enter this function.
-- Kept because it is the reference's line, not because it does anything.
local showBar = FauxScrollFrame_Update(scrollFrame, numFactions, NUM_FACTIONS_DISPLAYED, REPUTATIONFRAME_FACTIONHEIGHT)
-- Our trough is not the reference's business: it follows the bar the ref just decided
-- about (1860).
BenillaScrollTrough_Follow("ReputationListScrollFrame")
if not showBar then
getglobal("ReputationListScrollFrameScrollBar"):SetValue(0)
end
local factionOffset = FauxScrollFrame_GetOffset(scrollFrame)
local gender = UnitSex("player")
for i = 1, NUM_FACTIONS_DISPLAYED do
local factionIndex = factionOffset + i
if factionIndex <= numFactions then
BenillaReputationSlot_Paint(i, factionIndex, gender)
else
BenillaReputationSlot_Clear(i)
end
end
-- ref-lua l.146-148: nothing selected means nothing to detail. `GetSelectedFaction`
-- answers 0 both for "never selected" and for "the selected faction is folded away
-- under a collapsed header" (reputation.rs), so a fold closes the popup too.
if GetSelectedFaction() == 0 then
ReputationDetailFrame:Hide()
end
end
-- A bar click toggles the popup for that faction (ref-lua ReputationBar_OnClick,
-- l.151-159): the row that is already showing shuts it, any other row moves the selection
-- and opens it. Parameterless and reading `this`, exactly as the reference writes it: our
-- dispatcher sets `this` before invoking a handler (script/event.rs `invoke_with_globals`)
-- and it stays set through the call, the same contract ScrollTemplates.xml's
-- `FauxScrollFrame_OnVerticalScroll` already relies on.
function ReputationBar_OnClick()
if ReputationDetailFrame:IsShown() and GetSelectedFaction() == this.id then
ReputationDetailFrame:Hide()
return
end
SetSelectedFaction(this.id)
ReputationDetailFrame:Show()
ReputationFrame_Update()
end
-- A header click folds or unfolds its group (ref l.9-15, the template's own OnClick).
-- THE ADDED LINE is the repaint: the reference's `Collapse`/`ExpandFactionHeader` are C
-- bindings that raise UPDATE_FACTION on their way out, ours are pure model writes (this
-- file's header comment, deviation 3) — SkillFrame.xml's `BenillaSkillTypeLabel_OnClick`
-- carries the identical addition for the identical reason.
--
-- `header.index` is the slot's own VISIBLE row index, stamped by the update loop that last
-- painted it, and it must stay that: an index that is not a header — `0`, a bar row, a stale
-- one past the end — folds or unfolds EVERY header instead (the client's own fall-through at
-- `0x4d6a50`/`0x4d6aa0`, which reputation.rs reproduces). Nothing here can reach that arm,
-- because a slot only ever shows its header twin while the loop has just written a header
-- index into it; the note is here so a future edit does not reach it by accident.
function BenillaReputationHeader_OnClick(header)
if header.isCollapsed then
ExpandFactionHeader(header.index)
else
CollapseFactionHeader(header.index)
end
ReputationFrame_Update()
end
-- The popup's three boxes (ref l.758-766, l.810-818, l.839-848). The at-war box is the
-- reference's own body unchanged — it already repaints. The inactive box gains the added
-- repaint (this file's header comment, deviation 3): its flip re-parents the row under the
-- "Inactive" header there and then, so the list on screen is stale the moment it returns.
-- The main-screen box gets NO addition — see the same note for why the round trip, not a
-- repaint, is what moves the watched checkmark.
function BenillaReputationAtWarBox_OnClick(box)
FactionToggleAtWar(GetSelectedFaction())
if box:GetChecked() then
PlaySound("igMainMenuOptionCheckBoxOn")
else
PlaySound("igMainMenuOptionCheckBoxOff")
end
ReputationFrame_Update()
end
function BenillaReputationInactiveBox_OnClick(box)
if box:GetChecked() then
PlaySound("igMainMenuOptionCheckBoxOn")
SetFactionInactive(GetSelectedFaction())
else
PlaySound("igMainMenuOptionCheckBoxOff")
SetFactionActive(GetSelectedFaction())
end
ReputationFrame_Update()
end
function BenillaReputationMainScreenBox_OnClick(box)
if box:GetChecked() then
PlaySound("igMainMenuOptionCheckBoxOn")
SetWatchedFactionIndex(GetSelectedFaction())
else
PlaySound("igMainMenuOptionCheckBoxOff")
SetWatchedFactionIndex(0)
end
ReputationWatchBar_Update()
end
-- The window's wheel handler (this file's header comment, deviation 7).
function BenillaReputationFrame_OnMouseWheel(delta)
BenillaFauxScrollFrame_OnMouseWheel(getglobal("ReputationListScrollFrame"), delta)
end
-- ── Lifecycle (ref-lua l.16-30) ─────────────────────────────────────────────────────────
function ReputationFrame_OnLoad()
local frame = this
frame:RegisterEvent("UPDATE_FACTION")
-- The repaint is wired in the scroll frame's own `<OnVerticalScroll>` below, which is
-- where the reference's kit puts it (1860).
end
function ReputationFrame_OnShow()
ReputationFrame_Update()
end
function ReputationFrame_OnEvent(event)
if event == "UPDATE_FACTION" then
if this:IsVisible() then
ReputationFrame_Update()
end
end
end
]]></Script>
<!--
The category header row (ref l.4-56): 302x13, the +/- fold icon at LEFT(3,0) and the group
name at LEFT(20,0). The handler is a chunk function, not the reference's inline body, so the
added repaint has a home with a comment on it (chunk-boundary rule, SkillFrame.xml l.96-100).
-->
<Button name="ReputationHeaderTemplate" virtual="true">
<Size><AbsDimension x="302" y="13"/></Size>
<Scripts>
<OnClick>BenillaReputationHeader_OnClick(self)</OnClick>
</Scripts>
<NormalTexture file="Interface\Buttons\UI-MinusButton-UP">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors>
</NormalTexture>
<HighlightTexture name="$parentHighlight" file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors>
</HighlightTexture>
<ButtonText name="$parentNormalText">
<Size><AbsDimension x="275" y="12"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="20" y="0"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>
<HighlightFont inherits="GameFontHighlight" justifyH="LEFT"/>
<DisabledFont inherits="GameFontHighlight" justifyH="LEFT"/>
</Button>
<!--
The faction row (ref l.57-210): a 137x13 StatusBar carrying the whole row's furniture — the
two-piece frame art that reaches 126px LEFT of the bar itself, the name and standing
FontStrings, the hover/selection highlight pair, the watched checkmark, and the at-war
pennant hanging off the right end. The HitRectInsets stretch the row's MOUSE rect back over
the name (ref l.61-63: a NEGATIVE left inset widens), so clicking the faction's name selects
it just like clicking its bar.
`enableMouse="true"` is ADDED (this file's header comment, deviation 5) — a StatusBar is not
mouse-enabled by construction in this engine, and without it the OnEnter/OnLeave/OnMouseUp
below would never fire.
-->
<StatusBar name="ReputationBarTemplate" virtual="true" enableMouse="true" drawLayer="BACKGROUND" minValue="0" maxValue="1" defaultValue="1">
<Size><AbsDimension x="137" y="13"/></Size>
<HitRectInsets><AbsInset left="-126" right="3" top="-2" bottom="-2"/></HitRectInsets>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentReputationBarLeft" file="Interface\PaperDollInfoFrame\UI-Character-ReputationBar">
<Size><AbsDimension x="256" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="-126" y="4"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="1.0" top="0" bottom="0.34375"/>
</Texture>
<Texture name="$parentReputationBarRight" file="Interface\PaperDollInfoFrame\UI-Character-ReputationBar">
<Size><AbsDimension x="16" y="24"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentReputationBarLeft" relativePoint="TOPRIGHT"/></Anchors>
<TexCoords left="0" right="0.0625" top="0.34375" bottom="0.71875"/>
</Texture>
<FontString name="$parentFactionName" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size><AbsDimension x="100" y="10"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="-119" y="0"/></Offset></Anchor></Anchors>
</FontString>
<!-- The reference's `text="Revered"` placeholder is dropped (this file's header
comment, deviation 6). -->
<FontString name="$parentFactionStanding" inherits="GameFontHighlightSmall">
<Anchors><Anchor point="CENTER"/></Anchors>
</FontString>
</Layer>
<Layer level="OVERLAY">
<Texture name="$parentHighlight1" file="Interface\PaperDollInfoFrame\UI-Character-ReputationBar-Highlight" alphaMode="ADD" hidden="true">
<Size><AbsDimension x="256" y="28"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentReputationBarLeft"><Offset><AbsDimension x="-2" y="3"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="1" top="0" bottom="0.4375"/>
</Texture>
<Texture name="$parentHighlight2" file="Interface\PaperDollInfoFrame\UI-Character-ReputationBar-Highlight" alphaMode="ADD" hidden="true">
<Size><AbsDimension x="17" y="28"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentHighlight1" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="0.06640625" top="0.4375" bottom="0.875"/>
</Texture>
<Texture name="$parentCheck" file="Interface\Buttons\UI-CheckBox-Check" hidden="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentFactionName" relativePoint="RIGHT"/></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The crossed-swords pennant at the row's right end, shown for a faction you are at
war with (ref l.150-187). `enableMouse` added for its hover, same reason as the
bar's. -->
<Frame name="$parentAtWarCheck" enableMouse="true" hidden="true">
<Size><AbsDimension x="24" y="22"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentReputationBarRight" relativePoint="RIGHT">
<Offset><AbsDimension x="-2" y="0"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-ReputationBar">
<Size><AbsDimension x="24" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0.0625" right="0.15625" top="0.34375" bottom="0.71875"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(REPUTATION_STATUS_AT_WAR, nil, nil, nil, nil, 1)
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
</Frames>
<!-- ref l.189-207 is the specification: hovering swaps the standing word for the raw
"value / max" the update stamped on the bar and lights the glow; leaving puts the word
back and drops the glow again — unless this row is the SELECTED one, which keeps it.
Both strings and both glow states were put on the bar by the update above, so the
hover only chooses between them; the glow pair goes through that function's own verb
so a hover and a repaint can never disagree about which textures are up. -->
<Scripts>
<OnEnter>
if this.tooltip then
getglobal(this:GetName() .. "FactionStanding"):SetText(this.tooltip)
end
BenillaReputationBar_SetGlow(this:GetName(), 1)
</OnEnter>
<OnLeave>
getglobal(this:GetName() .. "FactionStanding"):SetText(this.standingText)
BenillaReputationBar_SetGlow(this:GetName(), GetSelectedFaction() == this.id)
</OnLeave>
<OnMouseUp>ReputationBar_OnClick()</OnMouseUp>
</Scripts>
<BarTexture file="Interface\PaperDollInfoFrame\UI-Character-Skills-Bar"/>
<BarColor r=".25" g=".25" b=".75"/>
</StatusBar>
<!-- id=3, the reference's own (ref l.211): a page's id is the slot it occupies in the WINDOW'S
OWN tab row, and ToggleCharacter selects the tab from it (ref CharacterFrame.lua:11).
`character_tests.rs` pins every page's id against its slot in
CHARACTERFRAME_SUBFRAMES. -->
<Frame name="ReputationFrame" enableMouse="true" parent="CharacterFrame" hidden="true" id="3">
<!-- The real 384x512 page. The explicit `<Size>` + TOPLEFT `<Anchor>` below is an exact
equivalent of the reference's `setAllPoints="true"` here — CharacterFrame, the parent
this frame declares, is itself 384x512 (ref CharacterFrame.xml l.4-7) — and it is kept
rather than swapped because it is the geometry the page ships with, not because the
attribute would not work. SkillFrame.xml and PetPaperDollFrame.xml both carry the
identical pair. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterFrame" relativePoint="TOPLEFT"/></Anchors>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="75"/></HitRectInsets>
<Layers>
<Layer level="BACKGROUND">
<!-- The four quadrant slabs (ref l.216-264), at the reference's own four TOPLEFT
offsets. The "General" pair is the variant every PaperDollInfoFrame page shares
and carries the portrait-ring cutout (this file's header comment). -->
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="258" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-BottomLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-257"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-BottomRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="258" y="-257"/></Offset></Anchor></Anchors>
</Texture>
<!-- The two column headings (ref l.265-282). `text=` carries the GlobalStrings KEY,
which the loader resolves against the shipped strings (loader `resolve_text`),
so the row is localized like the reference's. -->
<FontString name="ReputationFrameFactionLabel" inherits="GameFontHighlight" text="FACTION">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="70" y="-57"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="ReputationFrameStandingLabel" inherits="GameFontHighlight" text="STANDING">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="215" y="-59"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The 15 row slots (ref l.286-555): bar 1 at TOPLEFT(150,-86), each next chained
BOTTOMLEFT to TOPLEFT by (0,-10) — a 23px pitch over a 13px bar. Every header sits
at its own bar's TOPLEFT minus 125, so the two occupy the same row and only one of
them is ever shown. Interleaved bar/header exactly as the reference declares them,
which is also their z order. -->
<StatusBar name="ReputationBar1" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="150" y="-86"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader1" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar1"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar2" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader2" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar2"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar3" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader3" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar3"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar4" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader4" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar4"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar5" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader5" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar5"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar6" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader6" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar6"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar7" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader7" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar7"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar8" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader8" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar8"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar9" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar8" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader9" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar9"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar10" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar9" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader10" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar10"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar11" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar10" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader11" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar11"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar12" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar11" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader12" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar12"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar13" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar12" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader13" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar13"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar14" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar13" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader14" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar14"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<StatusBar name="ReputationBar15" inherits="ReputationBarTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar14" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</StatusBar>
<Button name="ReputationHeader15" inherits="ReputationHeaderTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="ReputationBar15"><Offset><AbsDimension x="-125" y="0"/></Offset></Anchor></Anchors>
</Button>
<!-- The scroll bar's TROUGH — the shared kit's template, replacing the reference's own
two loose textures (ref l.572-600; this file's header comment, deviation 4). It
carries NO anchors: it seats itself on its bar the first time
BenillaScrollBar_SetShown touches it, and the offsets that seat it were DERIVED
from this very window — ScrollTemplates.xml's `BenillaScrollTrough_Seat` cites
ref l.573-599 against ref-UIPanelTemplates.xml l.166-181 for its 21-above /
20-below / 8-left hang. Checked against the numbers below rather than assumed: the
kit's bar hangs at the scroll frame's TOPRIGHT+(6,-16) / BOTTOMRIGHT+(6,+16), so
the seated trough lands at TOPRIGHT+(-2,+5) and BOTTOMRIGHT+(29,-4) — 31 wide,
and the reference's own (-2,+5)/(-2,-4) for a 31-wide piece, to the pixel.
Declared BEFORE the faux frame so it draws beneath the bar. -->
<Frame name="ReputationListScrollFrameScrollBarTrough" inherits="BenillaScrollTroughTemplate"/>
<!-- The faux scroll frame (ref l.556-602): 296x354 at the page's TOPRIGHT(-66,-76),
which puts its left edge on the header column and its right edge 66 in from the
page. Its `<OnVerticalScroll>` is what re-binds the rows (1860). -->
<ScrollFrame name="ReputationListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="296" y="354"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-66" y="-76"/></Offset></Anchor></Anchors>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here: the bar's
OnValueChanged calls `SetVerticalScroll`, which lands in this handler.
Our transcription used a `frame.updateFunc` field instead, which the
reference does not have — without this the bar moves and the list does
not follow (1860). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(REPUTATIONFRAME_FACTIONHEIGHT, ReputationFrame_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The two newbie tips over the column headings (ref l.603-630): invisible frames
exactly covering their FontString. `enableMouse` added, same engine rule as the bar
template's. -->
<Frame name="FactionMouseOver" enableMouse="true">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="ReputationFrameFactionLabel"/>
<Anchor point="BOTTOMRIGHT" relativeTo="ReputationFrameFactionLabel"/>
</Anchors>
<Scripts>
<OnEnter>GameTooltip_AddNewbieTip(FACTION, 1.0, 1.0, 1.0, REPUTATION_FACTION_DESCRIPTION, 1)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="StandingMouseOver" enableMouse="true">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="ReputationFrameStandingLabel"/>
<Anchor point="BOTTOMRIGHT" relativeTo="ReputationFrameStandingLabel"/>
</Anchors>
<Scripts>
<OnEnter>GameTooltip_AddNewbieTip(STANDING, 1.0, 1.0, 1.0, REPUTATION_STANDING_DESCRIPTION, 1)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!--
The detail popup (ref l.631-852): a 212x203 dialog hanging off the window's own
TOPRIGHT, opened by clicking a bar and carrying the faction's description plus the
three switches. Declared LAST so it draws over the list.
-->
<Frame name="ReputationDetailFrame" enableMouse="true" hidden="true">
<Size><AbsDimension x="212" y="203"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="ReputationFrame" relativePoint="TOPRIGHT">
<Offset><AbsDimension x="-33" y="-28"/></Offset>
</Anchor>
</Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<FontString name="ReputationDetailFactionName" inherits="GameFontNormal" justifyH="LEFT">
<Size><AbsDimension x="0" y="12"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="20" y="-21"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="ReputationDetailFactionDescription" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size><AbsDimension x="170" y="0"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="ReputationDetailFactionName" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="0" y="-2"/></Offset>
</Anchor>
</Anchors>
</FontString>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Reputation-DetailBackground">
<Size><AbsDimension x="256" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="11" y="-11"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<Texture name="ReputationDetailCorner" file="Interface\DialogFrame\UI-DialogBox-Corner">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-6" y="-7"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="ReputationDetailDivider" file="Interface\DialogFrame\UI-DialogBox-Divider">
<Size><AbsDimension x="256" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="9" y="-131"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The reference's own `inherits="UIPanelCloseButton"` (ref l.720), and this is
the FIRST benilla window to consume that shared template rather than
carrying a private copy of the art — the posture UIPanelTemplates.xml's toc
entry describes as the queue it is waiting in. Its OnClick is
`HideUIPanel(this:GetParent())`, which for a frame that is not a registered
UIPanel falls through UiPanels.xml's walk to a plain `frame:Hide()` — the
reference's behaviour on this same button. -->
<Button name="ReputationDetailCloseButton" inherits="UIPanelCloseButton">
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-3" y="-3"/></Offset></Anchor></Anchors>
</Button>
<!-- At War (ref l.729-791): the ONE box the reference builds from bare textures
rather than a template, because its checked art is the crossed swords
(UI-CheckBox-SwordCheck) at 32x32 rather than the 26x26 tick. -->
<CheckButton name="ReputationDetailAtWarCheckBox">
<Size><AbsDimension x="26" y="26"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="14" y="34"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentText" inherits="GameFontNormalSmall">
<Anchors><Anchor point="LEFT" relativePoint="RIGHT"><Offset><AbsDimension x="-2" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>
getglobal(this:GetName() .. "Text"):SetText(AT_WAR)
getglobal(this:GetName() .. "Text"):SetVertexColor(RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b)
</OnLoad>
<OnClick>BenillaReputationAtWarBox_OnClick(self)</OnClick>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(REPUTATION_AT_WAR_DESCRIPTION, nil, nil, nil, nil, 1)
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<NormalTexture file="Interface\Buttons\UI-CheckBox-Up"/>
<PushedTexture file="Interface\Buttons\UI-CheckBox-Down"/>
<HighlightTexture file="Interface\Buttons\UI-CheckBox-Highlight" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\UI-CheckBox-SwordCheck">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="3" y="-5"/></Offset></Anchor></Anchors>
</CheckedTexture>
<DisabledCheckedTexture file="Interface\Buttons\UI-CheckBox-Check-Disabled"/>
</CheckButton>
<!-- Move to Inactive (ref l.792-820) and Show as Experience Bar (ref l.821-850),
both on the reference's own `OptionsCheckButtonTemplate`
(OptionsFrameTemplates.xml). The template also carries an OnClick that plays
the on/off tick; the instance's own block below REPLACES it (the loader
SetScripts each `<Scripts>` in order, the instance last — ScrollTemplates.xml's
header states the rule), which is why the sound is not doubled. -->
<CheckButton name="ReputationDetailInactiveCheckBox" inherits="OptionsCheckButtonTemplate">
<Size><AbsDimension x="26" y="26"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="ReputationDetailAtWarCheckBoxText" relativePoint="RIGHT">
<Offset><AbsDimension x="3" y="0"/></Offset>
</Anchor>
</Anchors>
<HitRectInsets><AbsInset left="0" right="0" top="0" bottom="0"/></HitRectInsets>
<Scripts>
<OnLoad>getglobal(this:GetName() .. "Text"):SetText(MOVE_TO_INACTIVE)</OnLoad>
<OnClick>BenillaReputationInactiveBox_OnClick(self)</OnClick>
</Scripts>
</CheckButton>
<CheckButton name="ReputationDetailMainScreenCheckBox" inherits="OptionsCheckButtonTemplate">
<Size><AbsDimension x="26" y="26"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="ReputationDetailAtWarCheckBox" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="0" y="3"/></Offset>
</Anchor>
</Anchors>
<HitRectInsets><AbsInset left="0" right="0" top="0" bottom="0"/></HitRectInsets>
<Scripts>
<OnLoad>getglobal(this:GetName() .. "Text"):SetText(SHOW_FACTION_ON_MAINSCREEN)</OnLoad>
<OnClick>BenillaReputationMainScreenBox_OnClick(self)</OnClick>
</Scripts>
</CheckButton>
</Frames>
</Frame>
</Frames>
<Scripts>
<OnLoad>ReputationFrame_OnLoad()</OnLoad>
<OnShow>ReputationFrame_OnShow()</OnShow>
<OnEvent>ReputationFrame_OnEvent(event)</OnEvent>
<OnMouseWheel>BenillaReputationFrame_OnMouseWheel(arg1)</OnMouseWheel>
</Scripts>
</Frame>
</Ui>
@@ -1,121 +0,0 @@
<Ui>
<!--
Print screen's UI half (decision 1487) — the centred "Screen Captured" line, and the
`TakeScreenshot()` the SCREENSHOT binding runs.
WHAT THE REFERENCE DOES, and why this file's ordering is the whole feature. Its
Bindings.xml maps SCREENSHOT to `TakeScreenshot()`; that function hides this frame, THEN
asks the engine to capture; the engine answers later with SCREENSHOT_SUCCEEDED or
SCREENSHOT_FAILED, and only then does the text appear. So the message can never be in the
picture, on either path:
· the ordinary press — the outcome event lands frames after the readback, because the
readback is asynchronous (`crate::screenshot`'s writer reports through a channel);
· the double press inside the 1.5 s fade — the PREVIOUS shot's text is still on screen,
and the Hide() below is what takes it off before the shutter.
That second case is the one worth naming: it is the only way a "Screen Captured" could ever
have reached a file, and it is why `TakeScreenshot` hides rather than just letting the fade
run out. B261 asked for exactly this contract.
PROVENANCE. The mechanism, the globals' names, the 1.5 s fade and the two strings are the
reference's (its WorldFrame.xml/.lua carry them; the strings are GlobalStrings.lua l.3395-6,
quoted as data like every other user-visible byte in this tree). The code is ours: the
reference recomputes elapsed from `GetTime() - this.startTime` on every tick and ignores the
elapsed its OnUpdate is handed — we accumulate the handed value instead, which is one clock
read fewer per frame and one field fewer on the frame.
SEATING. `parent="WorldFrame" setAllPoints="true"` with a CENTER-anchored string — the
reference's own nesting (WorldFrame.xml l.42, inside WorldFrame's `<Frames>`), and it is
load-bearing rather than decorative. That file's header states the law in one line:
"Children of the world frame are visible even when the UI is turned off." benilla's
WorldFrame is a stub (UIParent.xml) rather than the 3D layer, but it is a stub with the
property that matters here — top-level, so nothing hides it.
This read `parent="UIParent"` until 1757, on a note that argued the two seats are the same
because both frames are full-screen and their CENTERs coincide. That was true of the
GEOMETRY and blind to the rest: decision 1734 restored `SetFullScreenFrame`'s
`UIParent:Hide()`, so under a fullscreen panel the UIParent seat is a hidden one — and the
case that exposes it is this frame's own, because `CinematicFrame` answers the SCREENSHOT
key by hand (`RunBinding`, 1701) precisely so a fly-by can be captured, and the
confirmation for that capture could never appear. TOOLTIP strata is the reference's own —
the confirmation has to be readable over whatever panel is open, and an explicit strata is
not dragged down by a BACKGROUND-strata parent.
-->
<Script>
<![CDATA[
-- The two lines this frame can show, byte-exact from the 1.12 enUS GlobalStrings
-- (interface.MPQ `GlobalStrings.lua`, line-cited) — user-visible text, so its bytes are the
-- contract. Note the capital C: it is "Screen Captured", not "Screen captured".
SCREENSHOT_SUCCESS = "Screen Captured"; -- GlobalStrings.lua:3396
SCREENSHOT_FAILURE = "Screen Capture Failed";-- GlobalStrings.lua:3395
-- Seconds the line takes to fade from full to gone. The reference's own value, and a
-- GLOBAL there too — an addon may read or retune it, so it keeps the name.
SCREENSHOT_STATUS_FADETIME = 1.5;
-- The SCREENSHOT binding's body. Hide first, capture second: see the header.
function TakeScreenshot()
if ( ScreenshotStatus:IsVisible() ) then
ScreenshotStatus:Hide();
end
Screenshot();
end
function ScreenshotStatus_OnLoad()
this:RegisterEvent("SCREENSHOT_SUCCEEDED");
this:RegisterEvent("SCREENSHOT_FAILED");
end
function ScreenshotStatus_OnEvent(event)
if ( event == "SCREENSHOT_FAILED" ) then
ScreenshotStatusText:SetText(SCREENSHOT_FAILURE);
else
ScreenshotStatusText:SetText(SCREENSHOT_SUCCESS);
end
-- The fade restarts from full on every outcome, so a second shot re-announces itself
-- at full strength instead of inheriting whatever alpha the last one had faded to.
this.fade = 0;
this:SetAlpha(1.0);
this:Show();
end
function ScreenshotStatus_OnUpdate(elapsed)
local fade = (this.fade or 0) + (elapsed or 0);
this.fade = fade;
if ( fade >= SCREENSHOT_STATUS_FADETIME ) then
this:Hide();
return;
end
this:SetAlpha(1.0 - fade / SCREENSHOT_STATUS_FADETIME);
end
]]>
</Script>
<Frame name="ScreenshotStatus" parent="WorldFrame" setAllPoints="true" frameStrata="TOOLTIP" hidden="true">
<Layers>
<Layer level="ARTWORK">
<FontString name="ScreenshotStatusText" inherits="SystemFont">
<Anchors>
<Anchor point="CENTER" relativeTo="UIParent">
<Offset>
<AbsDimension x="0" y="0"/>
</Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>
ScreenshotStatus_OnLoad();
</OnLoad>
<OnEvent>
ScreenshotStatus_OnEvent(event);
</OnEvent>
<OnUpdate>
ScreenshotStatus_OnUpdate(arg1);
</OnUpdate>
</Scripts>
</Frame>
</Ui>
+79 -24
View File
@@ -26,7 +26,8 @@
attached, so the detail is genuinely variable-height content, which is what the real kit is
for; `GossipFrame.xml`'s greeting pane is the same wiring).
The window is registered in `UIPanelWindows` (`UiPanels.xml`), which is what seats it in the
The window registers itself in `UIPanelWindows` (below; the table is the stock UIParent.lua's
since 1988), which is what seats it in the
left slot and gets it closed by `CloseAllWindows()` — i.e. by ESC — with no code here. Our
own toplevels deliberately stay OUT of `UISpecialFrames`: that table is the addons' (0544's
note, 1206).
@@ -40,6 +41,10 @@
feed and nothing to keep in step — the window polls the log it is a view of.
-->
<Script><![CDATA[
-- The slot-manager row (decision 1495): a left-area window one tier above every stock
-- panel's pushable, so opening a game window pushes the log aside rather than the reverse.
UIPanelWindows["BenillaScriptLogFrame"] = { area = "left", pushable = 9, whileDead = 1 }
BENILLA_SCRIPTLOG_ROWS = 13
BENILLA_SCRIPTLOG_ROW_HEIGHT = 16
-- How much of a message a ROW shows. The full text is the detail pane's job; a row is an
@@ -52,21 +57,38 @@
-- The first line of a message, trimmed to a row's width. A traceback's later lines are
-- indented continuations; on one row they read as noise.
-- `strsub` is BYTE-indexed (Lua 5.0 strings are byte strings, and benilla keeps them that
-- way — decision 1193), so a cut that lands inside a multi-byte character leaves half of
-- one. Every message here is UTF-8 and ours are full of em dashes, so this was not
-- theoretical: the log window's own repaint raised on the first warning it was ever asked
-- to show (2135/2138). Back the cut up over any continuation byte (`10xxxxxx`, i.e.
-- 0x80..0xBF) so it lands on a character boundary.
function BenillaScriptLog_RowText(message)
local cut = strfind(message, "\n", 1, 1)
local head = message
if cut then head = strsub(message, 1, cut - 1) end
if strlen(head) > BENILLA_SCRIPTLOG_ROW_CHARS then
head = strsub(head, 1, BENILLA_SCRIPTLOG_ROW_CHARS - 3) .. "..."
local last = BENILLA_SCRIPTLOG_ROW_CHARS - 3
while last > 0 do
local b = strbyte(head, last + 1)
if not b or b < 128 or b >= 192 then break end
last = last - 1
end
head = strsub(head, 1, last) .. "..."
end
return head
end
-- `load` rows are red and `error` rows amber, and the split is by CONSEQUENCE: a `load` row
-- means an addon is not running at all, which is the thing the player can act on. Colours
-- come from the shipped tables so this window matches the rest of the UI.
-- The three kinds, coloured by CONSEQUENCE — how much of the addon is broken:
-- `load` red an addon is not running at all (the thing a player can act on)
-- `error` amber code ran and raised; the addon is otherwise up
-- `warn` grey a call was accepted and quietly did not do what it said (2135)
-- Grey rather than a fourth bright colour because a warning is the least urgent of the
-- three and there are more of them: colour has to keep ranking the list, not just label
-- it. Colours come from the shipped tables so this window matches the rest of the UI.
function BenillaScriptLog_RowColor(kind)
if kind == "load" then return 1.0, 0.3, 0.3 end
if kind == "warn" then return 0.65, 0.65, 0.65 end
return 1.0, 0.82, 0.0
end
@@ -76,20 +98,24 @@
-- The summary line carries the one number a bug report needs and the window cannot
-- otherwise show: how many were DROPPED. `total` counts distinct failures ever
-- recorded; `shown` is what survived the cap.
-- "problems", not "errors": the log carries three kinds now and two of them are not
-- errors (2135). The window is still `/errors` and still opens on the same key —
-- what changed is that a count of 7 stopped meaning "7 things raised".
local summary
if total == 0 then
summary = "No errors this session."
summary = "No problems this session."
elseif total > shown then
summary = format("%d errors (%d older not kept)", total, total - shown)
summary = format("%d problems (%d older not kept)", total, total - shown)
elseif total == 1 then
summary = "1 error"
summary = "1 problem"
else
summary = format("%d errors", total)
summary = format("%d problems", total)
end
getglobal("BenillaScriptLogSummary"):SetText(summary)
local scrollFrame = getglobal("BenillaScriptLogListScrollFrame")
FauxScrollFrame_Update(scrollFrame, shown, BENILLA_SCRIPTLOG_ROWS, BENILLA_SCRIPTLOG_ROW_HEIGHT)
BenillaScrollTrough_Follow("BenillaScriptLogListScrollFrame")
local offset = FauxScrollFrame_GetOffset(scrollFrame)
local selectedRow = nil
@@ -146,7 +172,7 @@
end
end
if text == "" then
text = "Select an error above to read it in full."
text = "Select a row above to read it in full."
end
getglobal("BenillaScriptLogDetailText"):SetText(text)
end
@@ -172,7 +198,6 @@
end
function BenillaScriptLogFrame_OnLoad(self)
getglobal("BenillaScriptLogListScrollFrame").updateFunc = BenillaScriptLog_Update
-- The ring's icon, through the portrait verb rather than left at its `file=` — see the
-- texture's own note. MailFrame.xml's open-letter icon is the same fix.
SetPortraitToTexture("BenillaScriptLogFramePortrait",
@@ -207,13 +232,13 @@
if getglobal("BenillaScriptLogFrame"):IsVisible() then
BenillaScriptLog_Update()
end
DEFAULT_CHAT_FRAME:AddMessage("Script error log cleared.", 1.0, 1.0, 0.0)
DEFAULT_CHAT_FRAME:AddMessage("Script log cleared.", 1.0, 1.0, 0.0)
end
]]></Script>
<!-- One list row: the `#N xCount` index column, then the message head. Same shape as
BenillaCraftSkillButtonTemplate (fixed columns — this engine has no FontString-width-based
dynamic anchor). -->
<!-- One list row: the `#N xCount` index column, then the message head. Fixed columns, 1.12's
own list idiom — not a width limitation: a FontString with a zero-authored axis takes its
measured box into the resolve (`benilla-ui` `script/layout.rs`, decision 1664). -->
<Button name="BenillaScriptLogRowTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
@@ -270,15 +295,12 @@
</Layer>
</Layers>
<Frames>
<!-- The top-right X. This engine ships no UIPanelCloseButton; the minimize-button art
stands in, MacroFrame/TrainerFrame's precedent. -->
<Button name="BenillaScriptLogCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<!-- The top-right X: the chain's own `UIPanelCloseButton` (seated at benilla.toc's
`Interface\FrameXML\UIPanelTemplates.xml`, 757 lines above this file). Its
32x32, its three minimize-button state textures and its
`HideUIPanel(this:GetParent())` are byte-for-byte what stood here authored. -->
<Button name="BenillaScriptLogCloseButton" inherits="UIPanelCloseButton">
<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>HideUIPanel(getglobal("BenillaScriptLogFrame"))</OnClick></Scripts>
</Button>
<!-- Declared BEFORE the rows so row text paints over the glow (the house z-tiebreak). -->
@@ -312,7 +334,13 @@
<ScrollFrame name="BenillaScriptLogListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="208"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_SCRIPTLOG_ROW_HEIGHT, BenillaScriptLog_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The detail pane: the REAL scroll kit, because a message carries its `stack
traceback:` tail and is genuinely variable-height. GossipFrame's greeting pane is
@@ -371,4 +399,31 @@
<OnUpdate>BenillaScriptLogFrame_OnUpdate()</OnUpdate>
</Scripts>
</Frame>
<!-- benilla's own commands over the reference's SlashCmdList (decision 1948). The stock
ChatEdit_ParseText walks SlashCmdList after its own built-ins, so each of benilla's commands
(ui_chat::commands — the dev instruments and the three additions) hands its line to
SubmitChatInput, where ui_chat::input's parser still owns it. This file loads after the
reference's ChatFrame.xml, which is what declares the table. -->
<Script><![CDATA[
local BENILLA_COMMANDS = {
{ "CHATTEST", "/chattest" },
{ "SHOT", "/shot" },
{ "LIQUID", "/liquid" },
{ "CASTVIS", "/castvis" },
{ "PARTYTEST", "/partytest" },
{ "REACTION", "/reaction", "/react" },
{ "RELOAD", "/reload" },
{ "ERRORS", "/errors", "/err" },
{ "CONVERTRAID", "/convertraid" },
}
for _, row in ipairs(BENILLA_COMMANDS) do
local key = "BENILLA_" .. row[1]
for i = 2, table.getn(row) do
setglobal("SLASH_" .. key .. (i - 1), row[i])
end
SlashCmdList[key] = function(msg)
SubmitChatInput(row[2] .. " " .. (msg or ""))
end
end
]]></Script>
</Ui>
@@ -21,14 +21,13 @@
ScrollFrame + Slider + Lua). The real `FauxScrollFrameTemplate` routes the scrollbar's value
through its parent `ScrollFrame` (`OnValueChanged → parent:SetVerticalScroll(v)`; the
ScrollFrame's `OnVerticalScroll` then derives `offset = floor(v/itemHeight + 0.5)`). That
ScrollFrame adds exactly one thing over the Slider: a *scroll-range clamp* on the value. We
don't need it — the offset comes straight off the Slider value here — and in benilla it would be
a liability: the clamp reads the scroll child's RESOLVED height, which lags a tick behind the
`SetHeight` the faux update does, so a content resize + scroll in one tick could clamp against a
stale range. So benilla's faux frame is a plain `<Frame>` + a `Slider`: fewer moving parts, no
dependency on within-tick resolved rects, and the identical on-screen result (a smooth,
pixel-granularity thumb whose position maps 1:1 to the scroll offset). See 0251 for the full
rationale + the divergence from 0250's described (real-client) shape.
ScrollFrame adds nothing to the value on its way through: 0251 believed it clamped to the
scroll range and called the clamp a liability, and decision 2017 read the bytes — the
reference's `SetVerticalScroll` stores what the bar hands it. So the relay is a pure
pass-through, and benilla's faux frame is a plain `<Frame>` + a `Slider`: fewer moving parts
and the identical on-screen result (a smooth, pixel-granularity thumb whose position maps
1:1 to the scroll offset). See 0251 for the full rationale + the divergence from 0250's
described (real-client) shape.
VALUE MODEL. The scrollbar's value is in PIXELS: `[0, (numItems − numToDisplay) * itemHeight]`,
`valueStep = itemHeight`. The row offset is `floor(value / itemHeight + 0.5)` — sub-pixel
@@ -76,13 +75,12 @@
-- over the template's on inherit (framexml.rs `merge`), so it really is one. Duck-typed for
-- exactly that reason.
--
-- GAP, stated where it bites: `SetVerticalScroll` clamps into `[0, GetVerticalScrollRange()]`
-- (scrollframe.rs), and that range comes from the frame's SCROLL CHILD — which the ref's
-- `FauxScrollFrameTemplate` declares as `<ScrollChild><Frame name="$parentScrollChildFrame">`
-- and our XML loader has no `<ScrollChild>` element for. Until it does, an addon frame's
-- range reads 0, so the handler fires with arg1 = 0 and its list stays at the top. The
-- offset our own kit computes above is unaffected — it comes straight off the Slider, which
-- is the whole point of 0251.
-- The engine's SetVerticalScroll takes the bar's value as given (decision 2017 — the
-- reference's never reads the scroll range), so an addon frame's `<OnVerticalScroll>`
-- fires with this very value and its `FauxScrollFrame_OnVerticalScroll` derives the offset
-- from it, the reference's path end to end. The offset our own kit computes above is the
-- same number by construction — it comes straight off the Slider, which is the whole point
-- of 0251.
function BenillaFauxScrollBar_OnValueChanged(scrollBar, value)
local frame = scrollBar:GetParent()
local step = scrollBar:GetValueStep()
@@ -222,9 +220,16 @@
BenillaScrollBar_SetShown(bar, range > 0)
end
-- Pan by `delta` px; the engine clamps to [0, range].
-- Pan by `delta` px, clamped to [0, range] HERE: the engine's SetVerticalScroll stores
-- what it is given (decision 2017 — the reference's never reads the range), and the
-- reference's own wheel handler bounds itself the same way, through its bar's [min, max]
-- (ScrollFrameTemplate_OnMouseWheel → scrollBar:SetValue).
function BenillaScroll_Step(sf, delta)
sf:SetVerticalScroll(sf:GetVerticalScroll() + delta)
local target = sf:GetVerticalScroll() + delta
local range = sf:GetVerticalScrollRange()
if target > range then target = range end
if target < 0 then target = 0 end
sf:SetVerticalScroll(target)
BenillaScroll_SyncBar(sf)
end
@@ -394,6 +399,18 @@
</Layers>
</Frame>
<!-- ══ Carried here from UiPanels.xml when that file went stock (decision 1988) ══
The placeholder icon our UIParent.lua counterpart held and the reference's does not,
seated in the earliest file of ours every consumer loads after. The window tab template
and its settle stood here too until 1993 put them on the chain. -->
<Script><![CDATA[
-- The placeholder icon for an item/spell whose texture has not arrived yet (Craft, Quest,
-- QuestLog, Trainer and TradeSkill read it). Was ActionBar.xml's (stock since 1938), then
-- UiPanels.xml's (stock since 1988); this file is the earliest of ours every reader loads
-- after. A benilla name, so nothing stock reads it.
BENILLA_FALLBACK_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
]]></Script>
<!-- `FauxScrollFrameTemplate` and the `FauxScrollFrame_*`/`ScrollFrame_*`/`ScrollingEdit_*`
API are the REFERENCE's, off the player's chain (`Interface\FrameXML\UIPanelTemplates`,
seated below this file — so its declarations win). Our transcriptions stood here dead
-857
View File
@@ -1,857 +0,0 @@
<Ui>
<!--
benilla's Skills tab (decision 0437 phase 4): the 1.12 `SkillFrame`, re-expressed from the
reference FrameXML `SkillFrame.{xml,lua}` (session scratchpad `ref-ui/`, decision 0246
discipline — never committed) onto our own engine/feeds. Geometry/art-paths below are QUOTED
from the ref (line citations inline, into that same `SkillFrame.xml`/`.lua`); the Lua is
the ref's logic turned onto benilla's Era API (`GetNumSkillLines`/`GetSkillLineInfo`/
`Expand`/`CollapseSkillHeader`/`SetSelectedSkill`/`GetSelectedSkill`, decision 0437's skills
seam, `crates/benilla-ui/src/script/skills.rs`, built alongside this file).
A CHILD PAGE of `CharacterFrame` (ref: `parent="CharacterFrame"`, `id="4"` — ours too, since
the Reputation page landed and took slot 3 back), shown/
hidden by `CharacterFrame_ShowSubFrame` exactly like `PaperDollFrame` — but NOT a
physically nested `<Frame>` the way PaperDollFrame is: PaperDollFrame is declared INSIDE
CharacterFrame.xml's own `<Frames>` tree (a real engine parent/child), while this page is a
SEPARATE file's top-level `<Frame>` (CharacterFrame.xml is shared-tree with another session
this pass, so only its tab machinery is touched here — not its frame tree). `parent=` really attaches
(decision 1734; the loader resolves the name globally at load and it wins over the enclosing
element — `shipped_xml_tests::every_declared_parent_really_attaches` asserts it on the loaded
tree). The line above used to say the attribute was read nowhere, which was true once and had
outlived itself. Position still rides `<Anchors relativeTo="CharacterFrame">` — a NAMED
lookup that works regardless of tree position, the same mechanism every sibling-anchor in
this codebase uses, kept because it is the geometry this page ships with — loaded right after
`CharacterFrame.xml` in the load list so it
paints ON TOP of it (later file = higher z, the established house convention every top-level
window already relies on implicitly). Because a true engine Hide-cascade doesn't reach a
non-child, `CharacterFrame.xml`'s own `CharacterFrame_OnHide` was extended (its own
header comment) to loop-hide every `CHARACTERFRAME_SUBFRAMES` entry — the fix this
page's non-nested shape requires, done once so later non-nested tabs (Pet/Reputation/Honor)
inherit it for free.
NO OWN PORTRAIT (matches the ref: `ref-ui/SkillFrame.xml` l.130-213 draws no portrait Texture
at all): the shared quadrant background art below (`UI-Character-General-TopLeft/TopRight`,
the SAME "General" variant the ref uses, l.136-159) carries the SAME baked-in portrait-ring
cutout every PaperDollInfoFrame quadrant variant shares, so `CharacterFrame`'s own
portrait/name/level (drawn at a lower z, underneath) shows through unchanged — exactly the
ref's own real-client compositing (SkillFrame is a nested child there too, relying on the
same cutout rather than redrawing the portrait itself).
THE DETAIL PANE NESTS, as the ref's does. This file used to carry the FLAT-layout claim —
"only a plain child `<Frame>` loses [its Layers]" — quoted from TrainerFrame.xml. That claim
is FALSE and was retracted in decision 1517, which corrected TrainerFrame/TradeSkillFrame/
CraftFrame and missed this file: a fourth restatement, still asserting the constraint five
weeks after it was disproved. A runtime-shown child `<Frame>` renders its own `<Layers>`
FontStrings in this engine, and the ref's `SkillDetailScrollFrame` +
`SkillDetailScrollChildFrame` are therefore built here verbatim (below).
1517 §2 says a retracted reason is not by itself cause to move approved geometry — so the
mover here is not the retraction, it is DEMAND: pfUI's `skins/blizzard/character.lua` l.299-320
indexes `SkillDetailScrollFrameScrollBar`, `SkillDetailScrollChildFrame`, `SkillDetailCostText`
and `SkillDetailStatusBarUnlearnButton` by name, and its own skin then hides the scroll child
and reparents the three detail regions onto the scroll frame — i.e. it flattens the pane
ITSELF, which it can only do if the pane is nested to begin with. The pane also could not
keep its old size inside the ref's 296-wide viewport: the old detail bar reused the 271-wide
LIST-ROW template, whose 281-wide Border put the abandon button at x 312..344 against a
viewport ending at 318 — 26 of its 32 px clipped. So the detail bar is now the ref's OWN
declaration (211×15, Border 220×32, centred under the list) rather than a borrowed list row,
which is both what fits and what the real client draws. LOOK CHANGE, called out for the
director: the detail bar narrows and centres, and the ref's two `UI-ClassTrainer-ScrollBar`
trough textures now flank the pane's right edge (the ref draws them unconditionally, bar or
no bar). Reverting is one anchor + one Size.
v1 DEVIATIONS (each re-cited at its own site below):
1. THE DETAIL PANE OMITS THE TRAINING-POINT SPEND ROW, and nothing else. Its structure
(`SkillDetailScrollFrame` → `SkillDetailScrollChildFrame` → `SkillDetailCostText` +
`SkillDetailDescriptionText` + `SkillDetailStatusBar` → `$parentUnlearnButton`) is the
ref's, verbatim. What stays OUT is the ref's `$parentLeftArrow`/`$parentRightArrow`/
`$parentLearnSkillButton` trio and the `AddSkillUp`/`RemoveSkillUp`/`BuySkillTier`
calls behind them — driven by `stepCost`/`rankCost`/`numTempPoints`, which
`GetSkillLineInfo` always reports nil/0 (0437's own named out-of-scope: 1.12 training
points are dead data).
`SkillDetailCostText` IS built, because the ref's own no-cost path is a real path, not
a stub: ref `SkillFrame.lua` l.361 `SkillDetailCostText:Hide()` is the branch a live
1.12 server always lands on, and `BenillaSkillFrame_UpdateDetail` takes exactly that
branch every time. An empty hidden FontString here is the reference's own behaviour,
not a name published to satisfy a lookup (1189/1203).
The ABANDON BUTTON is real: `SkillDetailStatusBarUnlearnButton` (the ref's
`$parentUnlearnButton`) → the `UNLEARN_SKILL` confirm → `AbandonSkill` →
`CMSG_UNLEARN_SKILL`; the isAbandonable predicate is `SkillRaceClassInfo.flags & 0x20`
— the server's own gate, real 5875 data: primary professions only. It used to be
published as `SkillDetailUnlearnButton`, a name the reference does not have, while the
comment directly above it cited `$parentUnlearnButton` correctly — the fifth instance
this session of right-behaviour-under-a-private-name (1189).
2. NO SORT CHECKBOX (ref l.215-241's own XML-commented-out "Replace once code is ready..."
`SkillSortButton`): the ref itself ships this dead/commented, so there is nothing to
transcribe (TradeSkillFrame.xml's own "ref's own dead code is dropped" precedent).
3. NO ACCEPT BUTTON (ref l.318-337's `SkillFrameAcceptButton`) — it is XML-commented dead
in the ref, and it backs the training-point economy this pane doesn't ship (deviation 1),
so there is nothing to transcribe. Its neighbour `SkillFrameCancelButton` (l.339-357) is
NOT dead and IS built: the ref's comment closes at l.337, one line above it, and the real
client draws a CLOSE button in the page's bottom-right seat. This list used to claim both
were commented out, and that misread is exactly why that seat rendered empty — decision
1496. Only its `CancelSkillUps()` call drops (deviation 1's dead economy again); the two
hides are transcribed.
4. THE RANK TEXT FORMAT IS THE REF'S BYTE-EXACT ONE, not the task brief's own paraphrase:
`ref-ui/SkillFrame.lua` l.178-186 builds `rank .. " (" .. color .. modifier .. close ..
")/" .. max` (e.g. "57 (|cff20ff20+3|r)/75") — transcribed verbatim (the
reference file is the authority on the tuple/format, the brief's prose is a summary).
5. NO FILLBAR (ref's `$parentFillBar`, driven by `numTempPoints`): [`GetSkillLineInfo`]
always reports `numTempPoints = 0` (skills.rs's own module doc — 1.12 training points are
dead data), so the fill bar could never show — dropped outright, `TradeSkillFrame.xml`'s
OWN `BenillaTradeSkillRankFrame` (the identical StatusBar shape) already sets this local
precedent for the same reason.
6. NO HOVER TOOLTIP on a row: the ref's own `SkillFrame.lua` calls `GameTooltip` NOWHERE
(grepped the full reference file) — a skill line is pure descriptor state, nothing to
preview. Unlike Trainer/TradeSkill, this window needs no `GameTooltip:Set*Item` channel.
7. `LockHighlight`/`UnlockHighlight` ENGINE GAP (TalentFrame.xml/TradeSkillFrame.xml's own
"no engine equivalent" posture, grepped `benilla-ui/src/script/button.rs` — ships
texture-pairs/SetText/Enable/Disable/RegisterForClicks/SetButtonState/Click only): the
persistent selection glow is `SkillHighlight`, the separate overlay Frame
Trainer/TradeSkillFrame already use, moved onto the selected row's StatusBar.
8. GlobalStrings this window itself needs: NONE beyond the tab label `SKILLS` (already
declared in CharacterFrame.xml, quoted there byte-exact with its GlobalStrings.lua line
cite) — the ref's own `SkillFrame.lua` reads no other GlobalString.
Rows repaint on `SKILL_LINES_CHANGED` (registered here; `crates/benilla/src/ui_char.rs`'s
`watch_skill_ups`/`feed_skills` already fire it) and on `OnShow` (ref `SkillFrame_OnShow`,
l.4-6). The list is the house faux-scroll kit (ScrollTemplates.xml, the TrainerFrame/
TradeSkillFrame precedent) over 12 fixed row slots (ref `SKILLS_TO_DISPLAY = 12`,
`SkillFrame.lua:1` — the ref's own row count, and this page shares the ref's own 384×512
coordinate space since it fills the identical CharacterFrame page area).
-->
<Script><![CDATA[
-- ── Constants (ref SkillFrame.lua:1-2) ──────────────────────────────────────────────────────
-- SKILLS_TO_DISPLAY is the reference's own GLOBAL and is published as one. It was a chunk
-- local named SKILLS_TO_DISPLAY with the real name in a trailing comment — the
-- value transcribed, the NAME kept private, which is the same shape that cost us
-- BlackoutWorld and the five SkillFrame* children. Addons read it: pfUI's character skin
-- loops `for i = 1, SKILLS_TO_DISPLAY` over the row labels
-- (skins/blizzard/character.lua:277) and died on "'for' limit must be a number".
-- 12 is honest here rather than copied — this file declares exactly 12 SkillTypeLabel rows.
SKILLS_TO_DISPLAY = 12
local SKILLFRAME_SKILL_HEIGHT = 15 -- SKILLFRAME_SKILL_HEIGHT
-- GlobalStrings (ref GlobalStrings.lua:3478), chunk-local per the codebase idiom — safe
-- because only THIS chunk's functions read it; the nil-"All" trap (TradeSkillFrame.xml's
-- deviation notes) is the inline-XML-handler case, and no inline handler references it.
-- Its earlier ABSENCE here made format() throw in UpdateDetail on every row selection —
-- half of the 2026-07-17 "tabs are flaky" report (the silent-error half).
local SKILL_DESCRIPTION = "|cffffffff%s|r %s"
local UNLEARN_SKILL = "Do you want to unlearn %s?" -- GlobalStrings.lua:4446
local UNLEARN = "Unlearn" -- GlobalStrings.lua:4445
local UNLEARN_SKILL_TOOLTIP = "Unlearn this profession" -- GlobalStrings.lua:4447
-- Paint the rank StatusBar `bar` + its `SkillName`/`SkillRank` FontStrings for one skill-line
-- ENTRY row (never a header — callers guard that): the ref's own bar-fill + rank-text half of
-- `SkillFrame_SetStatusBar` (l.76-187) and the detail's twin `SkillDetailFrame_SetStatusBar`
-- (l.161-187, l.370-399) — the SAME formatting either pane uses, factored once here since our
-- minimal detail pane (deviation 1) needs nothing else from that function.
function BenillaSkillFrame_PaintBar(bar, skillName, skillRank, skillModifier, skillMaxRank)
local nameText = getglobal(bar:GetName() .. "SkillName")
local rankText = getglobal(bar:GetName() .. "SkillRank")
-- The trough behind the fill (ref `$parentBackground`), recoloured per branch alongside
-- the fill itself — ref SkillFrame.lua:158 (normal) and :167 (proficiency), and the
-- detail pane's identical pair at :356/:376. The ref sets the normal-skill pair BEFORE
-- its `skillMaxRank` gate and lets the proficiency arm overwrite it; setting each arm's
-- own pair here is the same result with no dead write. The ref's THIRD writer of this
-- texture — the `skillCostType == 1/2/3` green/yellow/red block at :96-111, and the
-- `stepCost` "Learn <skill>" gray at :126 — is unreachable for us and would be
-- overwritten here even if it weren't: `stepCost`/`rankCost` are always nil and
-- `numTempPoints` always 0 (skills.rs's module doc — dead 1.12 data, deviation 1/5), so
-- every entry row takes the ref's own `else -- Normal skill` arm.
local bgTexture = getglobal(bar:GetName() .. "Background")
nameText:SetText(skillName)
nameText:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
rankText:SetTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
if skillMaxRank == 1 then
-- ref SkillFrame.lua:161-167 verbatim: "If max rank in a skill is 1 assume that
-- its a proficiency" — a FULL gray bar and NO rank text. Two kinds of line land
-- here, and the ref can't tell them apart because it doesn't need to: the armor
-- proficiencies, which the server really does report as 1/1, and the single-rank
-- lines (class skills, Dual Wield, racials, the per-mount riding lines) whose
-- `skillMaxRank` the engine already overrode to 1 off
-- `SkillRaceClassInfo.flags & 0x400` (skills.rs's `SkillEntry::mono`) — which is
-- why a hunter's `Beast Mastery 300/300` draws gray and numberless here, exactly
-- as it does in the real client.
bar:SetMinMaxValues(0, 1)
bar:SetValue(1)
bar:SetStatusBarColor(0.5, 0.5, 0.5)
rankText:SetText("")
bgTexture:SetVertexColor(1.0, 1.0, 1.0, 0.5)
elseif skillMaxRank == 0 then
-- Not reachable off a live server (a line the player HAS always carries a max ≥ 1)
-- and the ref leaves this case to fall through its own if/elseif untouched —
-- which, on a recycled row, would leave the PREVIOUS skill's bar and rank text
-- sitting there. We blank instead: a stale-state hole is a quirk, not behaviour.
bar:SetMinMaxValues(0, 1)
bar:SetValue(0)
bar:SetStatusBarColor(0, 0, 0, 0)
rankText:SetText("")
-- back to the template's own declared base, so a recycled row shows a neutral empty
-- trough rather than the previous skill's blue or white. The base lives in the
-- TEXEL (the template's `<Color 1,1,1,0.2>`), so restoring it means an IDENTITY
-- vertex colour — writing 0.2 here would multiply it to 0.04 (decision 1101).
bgTexture:SetVertexColor(1.0, 1.0, 1.0, 1.0)
else
bar:SetStatusBarColor(0.0, 0.0, 1.0, 0.5)
bgTexture:SetVertexColor(0.0, 0.0, 0.75, 0.5)
bar:SetMinMaxValues(0, skillMaxRank)
bar:SetValue(skillRank)
-- the ref's rank-text law (SkillFrame.lua:178-186), re-expressed here (this file's
-- header comment, deviation 4).
if skillModifier == 0 then
rankText:SetText(skillRank .. "/" .. skillMaxRank)
else
local color = RED_FONT_COLOR_CODE
if skillModifier > 0 then
color = GREEN_FONT_COLOR_CODE .. "+"
end
rankText:SetText(skillRank .. " (" .. color .. skillModifier .. FONT_COLOR_CODE_CLOSE .. ")/" .. skillMaxRank)
end
end
end
-- Fill the detail pane (deviation 1) for the current `GetSelectedSkill()`, or blank it
-- when nothing is selected / the selection isn't a visible entry. The description body is
-- the ref's own (ref SkillFrame.lua l.211-217 hide/show alongside the bar; l.262 the
-- SKILL_DESCRIPTION format): `skillType` stays "" in EVERY reachable 1.12 branch — the
-- ref's single assignment ("Tertiary Skill:") is commented out in the reference itself —
-- so the format's first slot renders empty, byte-faithful. The unlearn button rides the
-- same fill (ref l.220-226): shown only for an abandonable line, carrying the name the
-- confirm popup formats and the index its accept abandons.
function BenillaSkillFrame_UpdateDetail()
local bar = getglobal("SkillDetailStatusBar")
local desc = getglobal("SkillDetailDescriptionText")
local unlearn = getglobal("SkillDetailStatusBarUnlearnButton")
-- The ref's own no-cost branch (ref SkillFrame.lua l.361), taken unconditionally:
-- stepCost/rankCost are always nil on a 1.12 server (deviation 1). Hidden here rather
-- than left `hidden="true"` from XML so the branch is visible as a branch — the ref
-- shows this string on the paths we don't have, and this is where it would be re-shown.
getglobal("SkillDetailCostText"):Hide()
local index = GetSelectedSkill()
if index == 0 then
bar:Hide()
desc:Hide()
unlearn:Hide()
return
end
local skillName, isHeader, _, skillRank, _, skillModifier, skillMaxRank, isAbandonable = GetSkillLineInfo(index)
if not skillName or isHeader then
bar:Hide()
desc:Hide()
unlearn:Hide()
return
end
bar:Show()
BenillaSkillFrame_PaintBar(bar, skillName, skillRank, skillModifier, skillMaxRank)
if isAbandonable then
unlearn.skillName = skillName
unlearn.index = index
unlearn:Show()
else
unlearn:Hide()
end
local skillType = ""
local skillDescription = select(13, GetSkillLineInfo(index)) or ""
desc:SetText(format(SKILL_DESCRIPTION, skillType, skillDescription))
desc:Show()
end
-- ── The abandon confirm + the unlearn button's handlers ──────────────────────────────────
-- The dialog is ref StaticPopup.lua:1121-1133 verbatim (minus the vestigial TEXT()
-- wrapper); its OnAccept receives dialog.data — the visible index the button stashed.
-- CANCEL stays the bare GlobalStrings global (DeathFrame.xml's own convention — the app
-- loads the real GlobalStrings.lua before any window file). The handlers are chunk
-- functions, NOT inline bodies, so the chunk-local strings above are visible to them
-- (the nil-"All" chunk-boundary rule, TradeSkillFrame.xml's deviation notes).
StaticPopupDialogs["UNLEARN_SKILL"] = {
text = UNLEARN_SKILL,
button1 = UNLEARN,
button2 = CANCEL,
OnAccept = function(index)
AbandonSkill(index)
end,
timeout = 60,
exclusive = 1,
whileDead = 1,
showAlert = 1,
hideOnEscape = 1
}
function BenillaSkillUnlearnButton_OnClick(btn)
local dialog = StaticPopup_Show("UNLEARN_SKILL", btn.skillName)
if dialog then
dialog.data = btn.index
end
end
function BenillaSkillUnlearnButton_OnEnter(btn)
GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
GameTooltip:SetText(UNLEARN_SKILL_TOOLTIP)
end
-- Paint list slot `i` (1..12) from the visible row at `index` (ref SkillFrame_SetStatusBar,
-- l.24-188 — the header branch, l.55-69, and the entry branch, l.76-187, collapsed: the
-- skillCostType coloring + stepCost/rankCost training-up arrows never apply here, since
-- GetSkillLineInfo always reports them nil/0, this file's header comment deviation 1).
function BenillaSkillFrame_SetRow(i, index, numRows)
local bar = getglobal("SkillRankFrame" .. i)
local label = getglobal("SkillTypeLabel" .. i)
if index > numRows then
bar:Hide()
bar.skillIndex = nil
label:Hide()
label.skillIndex = nil
return
end
local skillName, isHeader, isExpanded, skillRank, _, skillModifier, skillMaxRank = GetSkillLineInfo(index)
if isHeader then
bar:Hide()
bar.skillIndex = nil
label:Show()
label.skillIndex = index
label:SetText(skillName)
if isExpanded then
label:SetNormalTexture("Interface\\Buttons\\UI-MinusButton-Up")
else
label:SetNormalTexture("Interface\\Buttons\\UI-PlusButton-Up")
end
return
end
label:Hide()
label.skillIndex = nil
bar:Show()
bar.skillIndex = index
BenillaSkillFrame_PaintBar(bar, skillName, skillRank, skillModifier, skillMaxRank)
end
-- The list page (ref SkillFrame_UpdateSkills, l.402-448 — the dead
-- `UnitCharacterPoints("player")`/`SkillFrame.UpdatedSkillPoints` read, l.437-440, whose
-- result nothing downstream ever consumes even in the ref itself, is dropped — the
-- TradeSkillFrame.xml "ref's own dead code is dropped" precedent).
function BenillaSkillFrame_Update()
local numRows = GetNumSkillLines()
local scrollFrame = getglobal("SkillListScrollFrame")
FauxScrollFrame_Update(scrollFrame, numRows, SKILLS_TO_DISPLAY, SKILLFRAME_SKILL_HEIGHT)
local offset = FauxScrollFrame_GetOffset(scrollFrame)
local highlight = getglobal("SkillHighlight")
highlight:Hide()
local selected = GetSelectedSkill()
for i = 1, SKILLS_TO_DISPLAY do
local index = i + offset
BenillaSkillFrame_SetRow(i, index, numRows)
if selected == index and index <= numRows then
highlight:SetPoint("TOPLEFT", getglobal("SkillRankFrame" .. i), "TOPLEFT", -5, 0)
highlight:Show()
end
end
-- The collapse-all tab's +/- icon (ref l.417-429's own numHeaders/notExpanded scan).
local allExpanded = true
for i = 1, numRows do
local _, isHeader, isExpanded = GetSkillLineInfo(i)
if isHeader and not isExpanded then
allExpanded = false
end
end
local collapseAll = getglobal("SkillFrameCollapseAllButton")
if numRows == 0 then
collapseAll:Disable()
else
collapseAll:Enable()
end
if allExpanded then
collapseAll.collapsed = nil
collapseAll:SetNormalTexture("Interface\\Buttons\\UI-MinusButton-Up")
else
collapseAll.collapsed = 1
collapseAll:SetNormalTexture("Interface\\Buttons\\UI-PlusButton-Up")
end
BenillaSkillFrame_UpdateDetail()
BenillaSkillFrameExpandButtonFrame_Fit()
end
-- A rank-bar click selects it (ref SkillBar_OnClick, l.450-455).
function BenillaSkillRankFrame_OnClick(bar)
local index = bar.skillIndex
if not index then
return
end
SetSelectedSkill(index)
BenillaSkillFrame_Update()
end
-- A header click folds/unfolds its group (ref SkillLabelTemplate's own OnClick, l.72-80).
function BenillaSkillTypeLabel_OnClick(label)
local index = label.skillIndex
if not index then
return
end
local _, _, isExpanded = GetSkillLineInfo(index)
if isExpanded then
CollapseSkillHeader(index)
-- A fold reshuffles visible indices — an UNLEARN_SKILL confirm armed for a row
-- that no longer holds the selection must not fire against the wrong line (ref
-- SkillLabelTemplate's own OnClick, ref SkillFrame.xml l.72-78).
local dialog = StaticPopup_FindVisible("UNLEARN_SKILL")
if dialog and dialog.data ~= GetSelectedSkill() then
StaticPopup_Hide("UNLEARN_SKILL")
end
else
ExpandSkillHeader(index)
end
BenillaSkillFrame_Update()
end
-- The collapse-all tab (ref SkillFrameCollapseAllButton's own isExpanded flag, l.303-309,
-- driven the TrainerFrame.xml BenillaTrainerCollapseAllButton way since this engine's
-- CollapseSkillHeader(0)/ExpandSkillHeader(0) already fold/unfold every group at once).
function BenillaSkillFrameCollapseAllButton_OnClick()
local b = getglobal("SkillFrameCollapseAllButton")
if b.collapsed then
ExpandSkillHeader(0)
else
CollapseSkillHeader(0)
FauxScrollFrame_SetOffset(getglobal("SkillListScrollFrame"), 0)
end
BenillaSkillFrame_Update()
end
-- The tab is as wide as its own label (ref SkillFrameExpandButtonFrame's OnLoad, l.312-316:
-- SetWidth(SkillFrameCollapseAllButton:GetTextWidth()+45)) — the 45 is the two 8px caps plus
-- the label's 25px seat plus the margin the ref leaves before the right cap, and the middle
-- slab stretches into whatever that leaves off its own two anchors.
--
-- Run from BOTH the ref's own OnLoad and every Update, because a measure here is not the
-- ref's synchronous one: benilla seats the VM's font engine at the frame boundary
-- (`ui_script::extract`, ahead of the tick), and XML load happens BEFORE any frame — so the
-- OnLoad call reads 0 and the tab would keep a 45px stump forever. The zero guard is what
-- makes the pair safe: an unmeasured call leaves the width alone rather than squashing the
-- tab, and the first Update after the atlas exists fits it. (The house's own preference is
-- two opposing anchors and no Lua width — UiPanels.xml's tab-highlight note — but a span
-- can only be resolved from two anchors when both edges are frames; a text width has to be
-- asked for.)
function BenillaSkillFrameExpandButtonFrame_Fit()
local label = getglobal("SkillFrameCollapseAllButton")
local w = label:GetTextWidth()
if w and w > 0 then
getglobal("SkillFrameExpandButtonFrame"):SetWidth(w + 45)
end
end
-- The page's own CLOSE (ref SkillFrameCancelButton's OnClick, l.350-356). The ref's
-- CancelSkillUps() drops out with the training-point economy this pane doesn't ship (this
-- file's header comment deviation 1); the two hides are the ref's own, in its own order —
-- the page first, then the window (CharacterFrame_OnHide's subframe loop would reach this
-- page anyway, but the ref hides it explicitly and so do we).
function BenillaSkillFrameCancelButton_OnClick()
getglobal("SkillFrame"):Hide()
HideUIPanel(getglobal("CharacterFrame"))
end
-- The window's wheel handler (bubbled up the parent chain from a row) → one row via the faux
-- kit (TrainerFrame.xml's own note: the engine bubbles a spin up the parent chain).
function BenillaSkillFrame_OnMouseWheel(delta)
BenillaFauxScrollFrame_OnMouseWheel(getglobal("SkillListScrollFrame"), delta)
end
-- ── Lifecycle (ref SkillFrame_OnLoad/_OnShow/_OnEvent, l.4-22, collapsed to the events this
-- page's feed actually fires: CHARACTER_POINTS_CHANGED backs the training-point economy this
-- pane doesn't ship, this file's header comment deviation 1). ─────────────────────────────────
function SkillFrame_OnLoad()
local frame = this
frame:RegisterEvent("SKILL_LINES_CHANGED")
getglobal("SkillListScrollFrame").updateFunc = BenillaSkillFrame_Update
end
function SkillFrame_OnShow()
BenillaSkillFrame_Update()
end
function SkillFrame_OnEvent(event)
local frame = this
if event == "SKILL_LINES_CHANGED" and frame:IsVisible() then
BenillaSkillFrame_Update()
end
end
]]></Script>
<!--
The rank StatusBar row (ref SkillStatusBarTemplate, l.4-65 — the FillBar dropped, this file's
header comment deviation 5): 271×15, name+rank overlaid, the Border child Button for OnClick
+ hover highlight (TradeSkillFrame.xml's own BenillaTradeSkillRankFrame shape, verbatim).
-->
<StatusBar name="BenillaSkillRankTemplate" virtual="true" hidden="true" drawLayer="BACKGROUND" minValue="0" maxValue="1" defaultValue="0">
<Size><AbsDimension x="271" y="15"/></Size>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentSkillName" inherits="GameFontNormalSmall">
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="6" y="1"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentSkillRank" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size><AbsDimension x="128" y="0"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentSkillName" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
<Layer level="BACKGROUND">
<Texture name="$parentBackground"><Color r="1.0" g="1.0" b="1.0" a="0.2"/></Texture>
</Layer>
</Layers>
<Frames>
<Button name="$parentBorder">
<Size><AbsDimension x="281" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\PaperDollInfoFrame\UI-Character-Skills-BarBorder"/>
<HighlightTexture file="Interface\PaperDollInfoFrame\UI-Character-Skills-BarBorderHighlight"/>
<Scripts><OnClick>BenillaSkillRankFrame_OnClick(self:GetParent())</OnClick></Scripts>
</Button>
</Frames>
<BarTexture name="$parentBar" file="Interface\PaperDollInfoFrame\UI-Character-Skills-Bar"/>
<BarColor r="0.25" g="0.25" b="0.75"/>
</StatusBar>
<!--
The category header row (ref SkillLabelTemplate, l.66-129, minus the DisabledTexture — this
window never Disable()s a header): 285×14, the +/- fold icon + name text.
-->
<Button name="BenillaSkillHeaderTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="285" y="14"/></Size>
<Scripts><OnClick>BenillaSkillTypeLabel_OnClick(self)</OnClick></Scripts>
<NormalTexture file="Interface\Buttons\UI-MinusButton-UP">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors>
</NormalTexture>
<HighlightTexture file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors>
</HighlightTexture>
<ButtonText>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="25" y="0"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontHighlight" justifyH="LEFT"/>
</Button>
<!-- id=4, the ref's own: a page's id is the slot it occupies in the WINDOW'S OWN tab row, and
ToggleCharacter selects the tab from it (ref CharacterFrame.lua:11). This was 3 while
Reputation (the ref's 3) was not built; ReputationFrame.xml landed and took that slot, so
Skills is back on the reference's number. `character_tests.rs` pins every page's id against
its slot in CHARACTERFRAME_SUBFRAMES, which is what made the renumber loud rather
than silent — exactly as this comment predicted. -->
<Frame name="SkillFrame" enableMouse="true" parent="CharacterFrame" hidden="true" id="4">
<!-- The real 384×512 page. No <Anchors relativeTo> substitute for true parenting exists
(this file's header comment) — pinned to CharacterFrame's own TOPLEFT by name. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterFrame" relativePoint="TOPLEFT"/></Anchors>
<!-- Transcribed for fidelity (ref l.131-133); no loader wiring exists yet for the hit-rect
shrink (TradeSkillFrame.xml's own "transcribe the hook even if nothing drives it yet"
posture, TalentFrame.xml's precedent). -->
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="75"/></HitRectInsets>
<Layers>
<!-- The shared quadrant background (ref l.136-183): NO portrait redraw here (this file's
header comment) — "General" TopLeft/TopRight (the same variant every PaperDollInfoFrame
page shares, carrying the portrait-ring cutout) + SkillFrame's own BotLeft/BotRight. -->
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\SkillFrame-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\SkillFrame-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<!-- The divider bar below the list, above the detail pane (ref l.186-211's own
TOPLEFT(15,-290); nudged to -305 here to clear this file's own 12-row chain, whose
last row sits slightly lower than the ref's fixed-slot geometry — a pixel-fit
adjustment, not a structural deviation). -->
<Layer level="ARTWORK">
<Texture name="SkillHorizontalBarLeft" file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
<Size><AbsDimension x="256" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="15" y="-305"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="1.0" top="0" bottom="0.25"/>
</Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
<Size><AbsDimension x="75" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="SkillHorizontalBarLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="0.29296875" top="0.25" bottom="0.5"/>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The expand/collapse-all tab (ref SkillFrameExpandButtonFrame, l.242-317 — the SAME
QuestLogSortTab art TrainerFrame.xml's own BenillaTrainerExpandButtonFrame uses).
The tab is SIZED BY ITS LABEL, the ref's own OnLoad law (l.312-316:
SetWidth(GetTextWidth()+45)) — the middle slab stretches between the two 8px caps
off its OWN two anchors (ref l.279-287), so the 45 is exactly the two caps plus the
label's seat. Was a fixed 54 with a fixed 38 middle, copied off
TrainerFrame.xml rather than read off THIS ref; the label's own font fix below
(12px, not 10) would have run under the right cap at that width. -->
<Frame name="SkillFrameExpandButtonFrame">
<Size><AbsDimension x="54" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="70" y="-49"/></Offset></Anchor></Anchors>
<Layers><Layer level="BACKGROUND">
<Texture name="SkillFrameExpandTabLeft" file="Interface\QuestFrame\UI-QuestLogSortTab-Left"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
<Texture name="SkillFrameExpandTabRight" file="Interface\QuestFrame\UI-QuestLogSortTab-Right"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
<Texture name="SkillFrameExpandTabMiddle" file="Interface\QuestFrame\UI-QuestLogSortTab-Middle"><Size><AbsDimension x="0" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="SkillFrameExpandTabLeft" relativePoint="RIGHT"/><Anchor point="RIGHT" relativeTo="SkillFrameExpandTabRight" relativePoint="LEFT"/></Anchors></Texture>
</Layer></Layers>
<Frames>
<!-- The ALL fold (ref SkillFrameCollapseAllButton, l.292-310). Its face is the
ref's SkillLabelTemplate one — the SAME face every SkillTypeLabel row wears
(BenillaSkillHeaderTemplate above, l.83-128 of the ref): the +/- glyph at
LEFT+3, the label at LEFT+25, and GameFontHighlight — WHITE at 12px, not
GameFontNormalSmall's yellow 10. Spelled out here rather than inherited
because this button keeps its own OnClick (this engine's
CollapseSkillHeader(0)/ExpandSkillHeader(0) fold everything at once, so it
needs no skillIndex/isExpanded bookkeeping) and this loader CONCATENATES an
instance's children onto its template's rather than replacing them, so an
inherited <Scripts> would stand alongside ours. Its seat is the ref's own
(-3,-3) off the left cap: the tab art's visible lip sits BELOW the
texture's geometric middle, so the label rides 3px low, not 3px high — the
+3 copied from TrainerFrame.xml put the whole "- All" 6px above where the
reference seats it. -->
<Button name="SkillFrameCollapseAllButton" text="ALL">
<Size><AbsDimension x="40" y="22"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="SkillFrameExpandTabLeft" relativePoint="RIGHT"><Offset><AbsDimension x="-3" y="-3"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-MinusButton-Up"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors></NormalTexture>
<HighlightTexture file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors></HighlightTexture>
<ButtonText><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="25" y="0"/></Offset></Anchor></Anchors></ButtonText>
<NormalFont inherits="GameFontHighlight" justifyH="LEFT"/>
<Scripts><OnClick>BenillaSkillFrameCollapseAllButton_OnClick()</OnClick></Scripts>
</Button>
</Frames>
<Scripts><OnLoad>BenillaSkillFrameExpandButtonFrame_Fit()</OnLoad></Scripts>
</Frame>
<!-- The page's own CLOSE button (ref SkillFrameCancelButton, l.339-357: 80x22,
UIPanelButtonTemplate, CENTER of TOPLEFT (305,-422), text CLOSE). LIVE in the
reference, not dead: the ref's XML comment that swallows SkillFrameAcceptButton
CLOSES at l.337, one line ABOVE this button — this file's own header comment
deviation 3 read that boundary wrong and dropped a button the real client draws,
which is why the page's bottom-right seat sat empty (decision 1496). The
UIPanelButtonTemplate face is reconstructed locally, the house convention every
window here follows (CraftFrame.xml's BenillaCraftPanelButtonTemplate note) — one
site, so inline rather than a virtual template (GroupLootFrame.xml's own precedent
for a single ref-template button). -->
<Button name="SkillFrameCancelButton" text="CLOSE">
<Size><AbsDimension x="80" y="22"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="305" y="-422"/></Offset></Anchor></Anchors>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<Scripts><OnClick>BenillaSkillFrameCancelButton_OnClick()</OnClick></Scripts>
</Button>
<!-- The persistent selection highlight (Trainer/TradeSkillFrame's own precedent, this
file's header comment deviation 7): 281×15, ARTWORK-layer UI-Listbox-Highlight2.
Declared BEFORE the rows so the row text paints OVER the glow (same z-tiebreak). -->
<Frame name="SkillHighlightFrame" hidden="true">
<Size><AbsDimension x="281" y="15"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers><Layer level="ARTWORK">
<Texture name="SkillHighlight" file="Interface\Buttons\UI-Listbox-Highlight2">
<Anchors><Anchor point="TOPLEFT"/><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
</Layer></Layers>
</Frame>
<!-- 12 header-row slots (ref SkillTypeLabel1..12, l.358-465): 285×14, row1 LEFT of
TOPLEFT(22,-86); rows 2-12 chain by (0,-18). -->
<Button name="SkillTypeLabel1" inherits="BenillaSkillHeaderTemplate">
<Anchors><Anchor point="LEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-86"/></Offset></Anchor></Anchors>
</Button>
<Button name="SkillTypeLabel2" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel1"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel3" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel2"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel4" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel3"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel5" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel4"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel6" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel5"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel7" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel6"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel8" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel7"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel9" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel8"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel10" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel9"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel11" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel10"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<Button name="SkillTypeLabel12" inherits="BenillaSkillHeaderTemplate"><Anchors><Anchor point="LEFT" relativeTo="SkillTypeLabel11"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors></Button>
<!-- 12 rank-bar slots (ref SkillRankFrame1..12, l.746-853): 271×15, row1 TOPLEFT(38,-79);
rows 2-12 chain BOTTOMLEFT→TOPLEFT by (0,-3) — the SAME 18px pitch as the label chain
(15 bar height + 3 gap). -->
<StatusBar name="SkillRankFrame1" inherits="BenillaSkillRankTemplate" id="1">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="38" y="-79"/></Offset></Anchor></Anchors>
</StatusBar>
<StatusBar name="SkillRankFrame2" inherits="BenillaSkillRankTemplate" id="2"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame3" inherits="BenillaSkillRankTemplate" id="3"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame4" inherits="BenillaSkillRankTemplate" id="4"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame5" inherits="BenillaSkillRankTemplate" id="5"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame6" inherits="BenillaSkillRankTemplate" id="6"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame7" inherits="BenillaSkillRankTemplate" id="7"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame8" inherits="BenillaSkillRankTemplate" id="8"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame9" inherits="BenillaSkillRankTemplate" id="9"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame8" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame10" inherits="BenillaSkillRankTemplate" id="10"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame9" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame11" inherits="BenillaSkillRankTemplate" id="11"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame10" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<StatusBar name="SkillRankFrame12" inherits="BenillaSkillRankTemplate" id="12"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame11" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
<!-- The faux-scroll frame (FauxScrollFrameTemplate, ScrollTemplates.xml): invisible
container over the 12 rows; only its right-hand Slider scroll bar renders. Sized to the
12-row span (SKILLS_TO_DISPLAY × 18px pitch), TrainerFrame.xml's own
same-TOPLEFT-as-row1 convention. updateFunc set in SkillFrame_OnLoad. -->
<ScrollFrame name="SkillListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="296" y="216"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-79"/></Offset></Anchor></Anchors>
</ScrollFrame>
<!-- ═══ THE DETAIL PANE ═══ ref SkillFrame.xml l.513-747, transcribed as the ref
declares it (header: the FLAT-layout claim this file used to carry is retracted,
decision 1517). A real ScrollFrame over a real scroll child; `scrollBarHideable`
keeps the bar off, which for a one-paragraph description is every time. The two
flanking textures are the ref's own trough art on the SCROLL FRAME's own Layers
(ItemTextFrame.xml's ItemTextScrollFrame is this file's precedent for both) — the
ref draws them unconditionally, so they stand whether or not the bar does.
The scroll range is the child's whole SUBTREE box (decision 1338), which is why
the ref's 320x50 child is not the undersized thing it looks like. -->
<ScrollFrame name="SkillDetailScrollFrame" inherits="UIPanelScrollFrameTemplate">
<Size><AbsDimension x="296" y="107"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="SkillListScrollFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-ScrollBar">
<Size><AbsDimension x="30" y="100"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-3" y="5"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.46875" top="0" bottom="0.78125"/>
</Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-ScrollBar">
<Size><AbsDimension x="30" y="100"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="-2"/></Offset></Anchor></Anchors>
<TexCoords left="0.53125" right="1.0" top="0.21875" bottom="1.0"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<!-- A handler declared on an instance REPLACES the template's, it does not
chain (ItemTextFrame.xml's own note), so the template's OnLoad is re-called
by hand before this instance's own line. -->
<OnLoad>
ScrollFrame_OnLoad()
self.scrollBarHideable = 1
</OnLoad>
</Scripts>
<ScrollChild>
<Frame name="SkillDetailScrollChildFrame">
<Size><AbsDimension x="320" y="50"/></Size>
<Layers>
<Layer level="ARTWORK">
<!-- The training-point price tag (ref l.567-577). Built, never
filled: `GetSkillLineInfo` reports no stepCost/rankCost on a
1.12 server, so BenillaSkillFrame_UpdateDetail takes the ref's
OWN no-cost branch (ref SkillFrame.lua l.361) and hides it —
deviation 1. It still earns its place as the DESCRIPTION's
anchor target, which is the ref's own arrangement. -->
<FontString name="SkillDetailCostText" inherits="GameFontNormalSmall" justifyH="CENTER" hidden="true">
<Size><AbsDimension x="200" y="0"/></Size>
<Anchors><Anchor point="CENTER" relativeTo="$parent" relativePoint="TOP"><Offset><AbsDimension x="-10" y="-40"/></Offset></Anchor></Anchors>
</FontString>
<!-- The description body (ref l.579-591). Its anchor is XML now:
the flat layout had to set it in SkillFrame_OnLoad because the
bar was a forward reference on the page, and inside the child
the ref's own static TOP anchor resolves. -->
<FontString name="SkillDetailDescriptionText" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP" hidden="true">
<Size><AbsDimension x="275" y="0"/></Size>
<Anchors><Anchor point="TOP" relativeTo="$parent" relativePoint="TOP"><Offset><AbsDimension x="-5" y="-35"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The selected skill's rank bar (ref l.594-747), the ref's OWN
declaration rather than the borrowed 271-wide list row it used to
be: 211x15 under a 220x32 Border, centred on the child's TOP. The
Border is a TEXTURE here, as the ref has it — the list row's
Border is a Button because a row is clickable, and the detail bar
is not, so the old "its Border click is harmless here" hedge is
gone with the borrowing. Names match the list template's
($parentSkillName/$parentSkillRank/$parentBackground), so
BenillaSkillFrame_PaintBar drives both unchanged. -->
<StatusBar name="SkillDetailStatusBar" drawLayer="BACKGROUND" minValue="0" maxValue="1" defaultValue="0" hidden="true">
<Size><AbsDimension x="211" y="15"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="SkillDetailScrollChildFrame" relativePoint="TOP"><Offset><AbsDimension x="-10" y="-20"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentBorder" file="Interface\PaperDollInfoFrame\UI-Character-Skills-BarBorder">
<Size><AbsDimension x="220" y="32"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="$parentSkillName" inherits="GameFontNormalSmall">
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="6" y="1"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentSkillRank" inherits="GameFontHighlightSmall">
<Size><AbsDimension x="128" y="0"/></Size>
</FontString>
</Layer>
<Layer level="BACKGROUND">
<Texture name="$parentBackground">
<Color r="1.0" g="1.0" b="1.0" a="0.2"/>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The abandon button (ref $parentUnlearnButton, l.705-737,
verbatim art/geometry): the red circle-slash right of the
Border, shown by UpdateDetail only for an abandonable line.
HitRectInsets transcribed-inert (no loader wiring —
CharacterFrame.xml's engine-gap note). Handlers are chunk
functions (the chunk-boundary rule — OnEnter reads the
chunk-local UNLEARN_SKILL_TOOLTIP). -->
<Button name="$parentUnlearnButton" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBorder" relativePoint="RIGHT">
<Offset><AbsDimension x="-2" y="-1"/></Offset>
</Anchor>
</Anchors>
<HitRectInsets><AbsInset left="9" right="7" top="-7" bottom="10"/></HitRectInsets>
<Scripts>
<OnClick>BenillaSkillUnlearnButton_OnClick(self)</OnClick>
<OnEnter>BenillaSkillUnlearnButton_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<NormalTexture file="Interface\Buttons\CancelButton-Up"/>
<PushedTexture file="Interface\Buttons\CancelButton-Down"/>
<HighlightTexture file="Interface\Buttons\CancelButton-Highlight" alphaMode="ADD"/>
</Button>
</Frames>
<BarTexture name="$parentBar" file="Interface\PaperDollInfoFrame\UI-Character-Skills-Bar"/>
<BarColor r="0.25" g="0.25" b="0.75"/>
</StatusBar>
</Frames>
</Frame>
</ScrollChild>
</ScrollFrame>
</Frames>
<Scripts>
<OnLoad>SkillFrame_OnLoad()</OnLoad>
<OnShow>SkillFrame_OnShow()</OnShow>
<OnEvent>SkillFrame_OnEvent(event)</OnEvent>
<OnMouseWheel>BenillaSkillFrame_OnMouseWheel(arg1)</OnMouseWheel>
</Scripts>
</Frame>
</Ui>
@@ -0,0 +1,40 @@
<Ui>
<!--
BENILLA_ALLOW_OWN_UI=1 — the director's two LOOK deviations on the pet page's autocast
button, over the reference's own SpellBookFrame.xml (decisions 1392/1393; re-seated by 2014
onto the reference's own Model now that this engine renders it — 2013).
Since 2013 the `$parentAutoCast` <Model> on every SpellButton draws its own file
(Interface\Buttons\UI-AutoCastButton.mdx) as a tile: four bones chasing the corners of a
0.02-unit square, four spline emitters trailing GlowStar sparks off them, on the pane's own
clock. The reference declares it 36x36 at CENTER (1,1) at scale 1.22 — a 0.87x rim that
floats clear of the viewport edge, never clipped, washing the icon; the real 1.12 client
looks the same way (director-checked, 1392), so this is taste, not fidelity, and the
director chose the pet button's proportions instead:
· the shine pane squared on the button — 37x37 at CENTER (0,0), scale 1.48 — so the rim
runs ON the viewport edge (1.024x, the pet button's own ratio) and the tile's cell, the
widget's own scissor, halves every star; and so the glow and the brackets share a centre
(1393's "top and right are off" was the reference's own +1,+1);
· the corner brackets at 71.53 rather than the reference's 60 — the art fills 33/64 of
UI-AutoCastableOverlay.blp, and 71.53 = 37 x 58/30 puts the bracket square on this
button's corners at the pet button's 0.997x.
Reverting the widget to 1.12 exactly is: overlay 60, pane 36x36 CENTER (1,1), scale 1.22 —
i.e. deleting this file. Nothing else here: the reference's file is sourced unedited.
-->
<Script><![CDATA[
for i = 1, 12 do
local button = getglobal("SpellButton" .. i)
local overlay = getglobal("SpellButton" .. i .. "AutoCastable")
overlay:SetWidth(71.53)
overlay:SetHeight(71.53)
local pane = getglobal("SpellButton" .. i .. "AutoCast")
pane:SetWidth(37)
pane:SetHeight(37)
pane:ClearAllPoints()
pane:SetPoint("CENTER", button, "CENTER", 0, 0)
pane:SetModelScale(1.48)
end
]]></Script>
</Ui>
@@ -1,965 +0,0 @@
<Ui>
<!--
benilla's spellbook window (decision 0216 §8, slice 5 — the P-key window, the spell
SOURCE for the cursor-payload arc): the 384x512 book + the 12-spell page grid + the
right-edge skill-line tabs + the page-turn pair, dressed from the real 1.12
`SpellBookFrame.xml`/`.lua` — extracted from the MPQ chain to session scratch this
session (director-local reference artifacts, never committed — the 0068/0138 pattern;
line cites below are into that extraction's own `ref-SpellBookFrame.xml`/`.lua`).
Docks the left UIPanel slot exactly like Character/Merchant/Gossip/Quest/QuestLog
(`UiPanels.xml`'s `UIPanelWindows["SpellBookFrame"] = { area = "left", pushable =
0 }`, mirroring the ref's own `SpellBookFrame` row) — so it competes for/replaces those
windows, never stacks with them. `ToggleSpellBook(bookType)` (ref `SpellBookFrame.lua:
9-50`) is the show/hide entry point, and it is addon-facing: it wears the reference's bare
name for the same reason `ToggleCharacter`/`ToggleQuestLog` do. The app's 'P' binding calls
it with `BOOKTYPE_SPELL` (`ui_script/input.rs`, ref `Bindings.xml:582-583`).
Every frame this file defines wears the REFERENCE's name (`SpellBookFrame`, `SpellButton1`,
…), because addons address them by it — the `Benilla` prefix these carried until the
reference-naming pass is gone. A child with no counterpart in the 1.12 namespace keeps the
bare stripped name so `$parent`/`GetName()..` composition inside the family stays coherent.
The book MODEL (tabs = skill lines, the flat slot list, offsets) is built entirely by the
app from `PlayerActions.spells` through the `Spell.dbc`/`SkillLine.dbc` join
(`crate::ui_spellbook`) and pushed through `benilla_ui::script::spellbook`'s snapshot seam
— this file only ever reads it through `GetNumSpellTabs`/`GetSpellTabInfo`/`GetSpellName`/
`GetSpellTexture`/`IsSpellPassive`, exactly the ref's own Era API shape. `BOOKTYPE_SPELL`/
`BOOKTYPE_PET` are installed by the ENGINE (`spellbook.rs`'s `install()`), not this file's
own `<Script>` — the one deliberate exception to the ref's own home for them
(`SpellBookFrame.lua:5-6`), so the engine's OWN tests can exercise the pet-deferral path
without this XML (see that module's doc for the full reasoning). `SPELLS_PER_PAGE`/
`MAX_SKILLLINE_TABS`/`SPELLBOOK_PAGENUMBERS` stay here — genuine per-window UI state, the
ref's own home for them.
THE PET BOOK IS LIVE (decision 1032) — the second tab, its 12-slot page, the autocast
overlay + sparkle, the right-click autocast toggle, and the cast/pickup pair, all over
`crate::ui_pet_book`'s feed of `SMSG_PET_SPELLS`' own spell list. Two reference QUIRKS are
reproduced on purpose here and each carries a do-not-fix note at its site (the standing
rule from decision 1030): the pet page **ignores its page number**, and its page arrows
write the *spell* book's page counter. See `SpellBook_GetSpellID` and the two
page-turn handlers.
Named deferrals/OMITs (each re-cited at its own site below):
- `LEARNED_SPELL_IN_TAB`'s tab flash (`SpellBookTabFlashFrame`, `CheckButtonGlow`): no
such event exists yet (no "just learned a spell" signal split out of the general
`SPELLS_CHANGED` diff). The ref returns early on it for the pet book anyway.
- The CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE legs of the checked ring: the ring
itself is LIVE (`BenillaSpellButton_UpdateSelection`/`IsCurrentCast` over the
app-resolved slot verdict — the shapeshift arm makes Ghost Wolf's/a druid form's
slot glow while its form is up), but the trade-skill-window arm of the engine
predicate is unmodeled (the same named gap `ui_action`'s IsCurrentAction carries),
so those four events would re-read an arm that never fires — omitted with it.
- The `this.updateTooltip`/`OnUpdate` re-poll loop around the hover: `GameTooltip:SetSpell`
itself is LIVE (the engine channel, decision 0276's line law; it reads whichever book
`bookType` names — 1050), but its per-frame re-poll is dropped, since nothing in the
rendered lines changes per-frame the way a live cooldown would. The ref's own
`if ( GameTooltip:SetSpell(…) )` return value exists only to arm that loop.
- `maxLines="3"` on `$parentSpellName` (word-wrap line cap): no such FontString
attribute is modeled (grepped `benilla-ui/src/loader/widgets.rs`) — the name just
wraps unbounded inside its box width, a harmless cosmetic gap on the rare long name.
- `MultiActionBar_ShowAllGrids/_HideAllGrids()` (ref `SpellBookFrame_OnShow`/`_OnHide`):
no multi-bar grid-show mechanism exists in this engine yet — dropped, the same class of
gap CharacterFrame's own OnShow/OnHide already carry. Its neighbour in that pair,
`UpdateMicroButtons()`, is LIVE since the micro-menu slice (`MicroMenu.xml`) — nil-
guarded at the call sites only because several test harnesses load this window without
the action bar behind it.
Sounds (decision 0090): `igSpellBookOpen`/`igSpellBookClose` on show/hide (ref
`SpellBookFrame_Update`/`_OnHide`, VERIFIED present in the real `SoundEntries.dbc` this
session), `igAbiliityPageTurn` (the ref's own literal spelling — VERIFIED as the real
registered kit name, id 836, `Sound\interface\iAbilitiesTurnPage{A,B,C}.wav`; not a typo
to "fix") on both page-turn clicks.
-->
<Script><![CDATA[
-- ── GlobalStrings values this window uses (ref-GlobalStrings.lua, cited per line — the
-- `X = X or "..."` idiom matches CastingBar.xml/CharacterFrame.xml's own convention) ────
SPELLBOOK = SPELLBOOK or "Spellbook" -- :3820
PAGE_NUMBER = PAGE_NUMBER or "Page %d" -- :2981
-- The pet tab's label AND the window title while the pet book is up. Keyed, not chosen:
-- HasPetSpells()'s second return is ChrClasses.dbc field 4 ("PET"/"DEMON") and the ref
-- concatenates it (`getglobal("PET_TYPE_"..token)`, ref SpellBookFrame.lua:173).
PET_TYPE_PET = PET_TYPE_PET or "Pet" -- :3058
PET_TYPE_DEMON = PET_TYPE_DEMON or "Demon" -- :3057
-- ── This window's own constants (ref SpellBookFrame.lua:1-7, Benilla-prefixed where the
-- ref's own name would collide with the engine's BOOKTYPE_SPELL/BOOKTYPE_PET globals —
-- this file's header comment on that one deviation) ─────────────────────────────────────
SPELLS_PER_PAGE = 12
MAX_SKILLLINE_TABS = 8
SPELLBOOK_PAGENUMBERS = {}
-- ── The window: toggle (ref ToggleSpellBook, l.9-50) ─────────────────────────────────────
-- Kept BARE (not Benilla-prefixed) — the addon-facing entry point, matching this
-- codebase's own ToggleCharacter/ToggleQuestLog precedent. The ref's own DUPLICATED
-- prev/next enable/disable (once inside the `currentPage > maxPages` clamp branch, once
-- again unconditionally right after — both produce the identical end state) collapses to
-- the ONE SpellBook_UpdatePageArrows() call already shared by every other
-- page-turn site below — a redundant-code cleanup, not a behavior change.
function ToggleSpellBook(bookType)
-- ref l.11-14: asking for a pet book you haven't got does NOTHING — not even close a
-- spellbook that is already open. The TOGGLEPETBOOK key binding is the live caller.
if bookType == BOOKTYPE_PET and not HasPetSpells() then
return
end
local isVisible = SpellBookFrame:IsVisible()
HideUIPanel(SpellBookFrame)
if (not isVisible or (SpellBookFrame.bookType ~= bookType)) then
SpellBookFrame.bookType = bookType
ShowUIPanel(SpellBookFrame)
end
BenillaSpellBook_ClampPage()
SpellBook_UpdatePageArrows()
SpellBookPageText:SetText(format(PAGE_NUMBER, SpellBook_GetCurrentPage()))
end
-- Clamp the selected tab's page back onto range (ref's own inline logic inside
-- ToggleSpellBook, l.22-26) — split out so both ToggleSpellBook and a future re-entry
-- share it, rather than inlining it twice.
function BenillaSpellBook_ClampPage()
local currentPage, maxPages = SpellBook_GetCurrentPage()
if currentPage > maxPages then
SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] = maxPages
BenillaUpdateSpells()
end
end
function SpellBookFrame_OnLoad()
local frame = this
frame:RegisterEvent("SPELLS_CHANGED")
-- LEARNED_SPELL_IN_TAB OMITTED — this file's header comment.
frame.bookType = BOOKTYPE_SPELL
for i = 1, MAX_SKILLLINE_TABS do
SPELLBOOK_PAGENUMBERS[i] = 1
end
SPELLBOOK_PAGENUMBERS["pet"] = 1 -- ref l.66; read by GetCurrentPage, written by nothing
SpellBookSkillLineTab_OnClick(1)
end
-- ref SpellBookFrame_OnEvent (l.75-92), the SPELLS_CHANGED branch only (LEARNED_SPELL_IN_TAB
-- OMITTED, header comment).
function SpellBookFrame_OnEvent()
local frame = this
if event == "SPELLS_CHANGED" and frame:IsVisible() then
SpellBookFrame_Update()
SpellBook_UpdatePageArrows()
end
end
-- ref SpellBookFrame_OnShow (l.94-105): MultiActionBar_ShowAllGrids OMITTED (header
-- comment); the tab-flash UIFrameFlash call goes with LEARNED_SPELL_IN_TAB.
-- UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml).
function SpellBookFrame_OnShow()
if UpdateMicroButtons then UpdateMicroButtons() end
SpellBookFrame_Update(1)
end
-- ref SpellBookFrame_Update (l.107-164), whole. Three things happen here and they are
-- easy to conflate: the skill-line strip is gated on the book TYPE (the pet book has no
-- tabs at all — GetSpellTabInfo never sees it), the Spell/Pet toggle row is gated on
-- HasPetSpells(), and the title + open sound fork on which book is up.
function SpellBookFrame_Update(showing)
if showing then
SpellBookSkillLineTab_OnClick(SpellBookFrame.selectedSkillLine)
BenillaUpdateSpells()
end
local isPet = SpellBookFrame.bookType == BOOKTYPE_PET
local numSkillLineTabs = GetNumSpellTabs()
for i = 1, MAX_SKILLLINE_TABS do
local skillLineTab = getglobal("SpellBookSkillLineTab" .. i)
if i <= numSkillLineTabs and not isPet then
local name, texture = GetSpellTabInfo(i)
skillLineTab:SetNormalTexture(texture)
skillLineTab.tooltip = name
skillLineTab:Show()
skillLineTab:SetChecked(SpellBookFrame.selectedSkillLine == i)
else
skillLineTab:Hide()
end
end
-- ref l.108-111 + l.141-151: hide the row first, then raise exactly the tabs that
-- exist. Tab 3 never appears in 1.12 (no third book) and is hidden by the template's
-- own `hidden="true"`; it is kept so the anchor chain reads like the ref's.
SpellBookFrameTabButton1:Hide()
SpellBookFrameTabButton2:Hide()
local hasPetSpells, petToken = HasPetSpells()
SpellBookFrame.petTitle = nil
if hasPetSpells then
SpellBookFrame_SetTabType(SpellBookFrameTabButton1, BOOKTYPE_SPELL)
SpellBookFrame_SetTabType(SpellBookFrameTabButton2, BOOKTYPE_PET, petToken)
elseif isPet then
-- The pet died/was dismissed while its book was open: the ref closes the window
-- and reverts, rather than showing an empty pet page (l.147-151).
HideUIPanel(SpellBookFrame)
SpellBookFrame.bookType = BOOKTYPE_SPELL
isPet = false
end
if not isPet then
SpellBookTitleText:SetText(SPELLBOOK)
if showing then PlaySound("igSpellBookOpen") end
else
SpellBookTitleText:SetText(SpellBookFrame.petTitle)
-- ref l.161: the pet book opens on the ABILITY kit, not the spellbook one. Both
-- ship in the real SoundEntries.dbc.
if showing then PlaySound("igAbilityOpen") end
end
end
-- ref SpellBookFrame_SetTabType (l.166-183). `binding` is what the tab's tooltip appends
-- the bound key to (MicroButtonTooltipText); `petTitle` is set here as a side effect,
-- exactly as the ref does, because this is the only place the token is in hand.
function SpellBookFrame_SetTabType(tabButton, bookType, token)
if bookType == BOOKTYPE_SPELL then
tabButton.bookType = BOOKTYPE_SPELL
tabButton:SetText(SPELLBOOK)
tabButton.binding = "TOGGLESPELLBOOK"
else
tabButton.bookType = BOOKTYPE_PET
local label = getglobal("PET_TYPE_" .. (token or "PET")) or PET_TYPE_PET
tabButton:SetText(label)
tabButton.binding = "TOGGLEPETBOOK"
SpellBookFrame.petTitle = label
end
-- The tab for the book you are ON is disabled, which is also what draws it as the
-- selected (Tab1-Selected) plate — the ref uses <DisabledTexture> for the "current"
-- look rather than a checked state.
if SpellBookFrame.bookType == bookType then
tabButton:Disable()
else
tabButton:Enable()
end
tabButton:Show()
end
-- ref SpellBookFrame_OnHide (l.186-204): MultiActionBar_HideAllGrids and the tab-flash
-- cleanup OMITTED (header comment); always the spell-book close kit (no pet book to branch
-- on). UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml).
function SpellBookFrame_OnHide()
if UpdateMicroButtons then UpdateMicroButtons() end
if SpellBookFrame.bookType == BOOKTYPE_PET then
PlaySound("igAbilityClose") -- ref l.191
else
PlaySound("igSpellBookClose")
end
end
-- UpdateSpells(): the ref's OWN global (`SpellBookFrame.lua:26/116/411/426/450` — never
-- DEFINED in this session's extraction, a base-UI/C-level helper outside this file)
-- transcribed here as BenillaUpdateSpells(): refresh every spell button on the current
-- page (this file's own convention: explicit button params, not ambient `this`).
function BenillaUpdateSpells()
for i = 1, SPELLS_PER_PAGE do
BenillaSpellButton_UpdateButton(getglobal("SpellButton" .. i))
end
end
-- ── The spell button (ref SpellButton_*, l.206-404) ──────────────────────────────────────
-- CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE are OMITTED (header comment: no craft or
-- trade-skill window arm); SPELLS_CHANGED and SPELL_UPDATE_COOLDOWN drive the full refresh
-- (the ref's own shared branch, l.221) and CURRENT_SPELL_CAST_CHANGED the checked ring.
-- PET_BAR_UPDATE is LIVE (ref l.214 + l.227-231) and it is the pet page's whole repaint
-- wire: the pet book's cooldowns, autocast bits and active ring all move without the BOOK
-- changing, so SPELLS_CHANGED alone would leave a stale page.
function SpellButton_OnLoad()
local button = this
button:RegisterEvent("SPELLS_CHANGED")
button:RegisterEvent("CURRENT_SPELL_CAST_CHANGED")
button:RegisterEvent("SPELL_UPDATE_COOLDOWN")
button:RegisterEvent("PET_BAR_UPDATE")
button:RegisterForDrag("LeftButton")
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
button.autocastable = getglobal(button:GetName() .. "AutoCastable")
-- The ref's `$parentAutoCast` is a <Model> playing UI-AutoCastButton.mdx; ours is
-- the native shine lane (decision 1383 — PetActionBar.xml's sparkle header has the
-- story, `autocast_shine` the numbers): $parentShine is the token marker, and
-- showing it is the whole enable.
button.shine = getglobal(button:GetName() .. "Shine")
BenillaSpellButton_UpdateButton(button)
end
function SpellButton_OnEvent(event)
local button = this
if event == "SPELLS_CHANGED" or event == "SPELL_UPDATE_COOLDOWN" then
BenillaSpellButton_UpdateButton(button)
elseif event == "CURRENT_SPELL_CAST_CHANGED" then
BenillaSpellButton_UpdateSelection(button)
elseif event == "PET_BAR_UPDATE" then
if SpellBookFrame.bookType == BOOKTYPE_PET then
BenillaSpellButton_UpdateButton(button)
end
end
end
-- ref SpellButton_UpdateSelection (l.292-306): the checked ring — `IsCurrentCast` over
-- the app-resolved verdict (the ACTIVE shapeshift form's own spell — Ghost Wolf glows
-- while the shaman is the wolf, a druid form likewise; the engine predicate `0x4b3600`
-- deliberately has NO casting-now arm, so an ordinary cast never lights a book slot).
-- The ref's out-of-tab bounds fork collapses into the one SetChecked: IsCurrentCast
-- answers nil past the book's end already.
function BenillaSpellButton_UpdateSelection(button)
local id = SpellBook_GetSpellID(button:GetID())
button:SetChecked(IsCurrentCast(id, SpellBookFrame.bookType))
end
-- ref SpellButton_OnEnter (l.235-244): the engine's SetSpell — the byte-verified spell
-- line law (decision 0276: cost|range one line, casttime|cooldown one line, passives
-- omit it, the $-substituted gold description; no rank column from the book — the
-- verified showRank=0).
function BenillaSpellButton_OnEnter(button)
if not GameTooltip then return end -- engine tests load file subsets
local id = SpellBook_GetSpellID(button:GetID())
if not GetSpellName(id, SpellBookFrame.bookType) then
return
end
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
GameTooltip:SetSpell(id, SpellBookFrame.bookType)
end
-- ref SpellButton_OnClick (l.263-290): the MacroFrame branch and the right-click pet
-- autocast fork are both OMITTED (header comment) — shift-click always picks up (the
-- ref's own "no MacroFrame visible" fallthrough), every other click casts. The
-- `id > MAX_SPELLS` guard is dropped too: GetSpellName/CastSpell/PickupSpell already
-- answer nil/no-op for any id past the book's real length, so the extra guard checks
-- nothing a MAX_SPELLS constant would add here.
function SpellButton_OnClick(drag)
local button = this
-- The mouse button is the ambient `arg1`, exactly as the reference reads it (l.284) —
-- its XML passes nothing on OnClick and a bare `1` on the two drag slots. Only the
-- `drag` falsy arm below reaches it, so the drag slots never read a stale arg1.
local mouseButton = arg1
local id = SpellBook_GetSpellID(button:GetID())
button:SetChecked("false")
if drag then
PickupSpell(id, SpellBookFrame.bookType)
elseif not IsShiftKeyDown() and mouseButton and mouseButton ~= "LeftButton"
and SpellBookFrame.bookType == BOOKTYPE_PET then
-- ref l.284-285: on the PET page a non-left click flips autocast instead of
-- casting. Shift still outranks it — the ref tests shift first, so a shift
-- right-click on a pet spell picks it up.
ToggleSpellAutocast(id, SpellBookFrame.bookType)
elseif IsShiftKeyDown() then
-- ref SpellButton_OnClick l.271-283 (LIVE since decision 0983): with the macro
-- editor open a shift-click APPENDS `/cast <name>[(<rank>)]` to the body instead of
-- picking the spell up — the behaviour MACRO_HELP_TEXT_LINE5 advertises ("Shift
-- click a spell in your spellbook with the macro UI open to add it to your macro").
-- The `/cast` alias comes from the shipped strings (SLASH_CAST1), never typed here.
--
-- DO NOT "FIX" THIS TO INSERT THE BARE NAME (B248, decision 1328). The whole
-- `/cast` line is what 1.12.1 writes; the bare-name-at-the-cursor insert people
-- remember is a LATER client's (`ChatEdit_InsertLink`). And the gate is SHIFT,
-- not the mouse button: a plain right click on this page casts, same as a left one
-- — pinned both ways in `ui_script::spellbook_tests`.
local macroFrame = getglobal("MacroFrame")
if macroFrame and macroFrame:IsVisible() then
local spellName, subSpellName = GetSpellName(id, SpellBookFrame.bookType)
if spellName and not IsSpellPassive(id, SpellBookFrame.bookType) then
local line = (SLASH_CAST1 or "/cast") .. " " .. spellName
if subSpellName and string.len(subSpellName) > 0 then
line = line .. "(" .. subSpellName .. ")"
end
MacroFrame_AddMacroLine(line)
end
else
PickupSpell(id, SpellBookFrame.bookType)
end
else
CastSpell(id, SpellBookFrame.bookType)
BenillaSpellButton_UpdateSelection(button) -- ref l.288: the ring re-reads at once
end
end
-- ref SpellButton_UpdateButton (l.307-404): the autocast overlay/model is OMITTED
-- (header comment); the cooldown sweep (l.359-365) and the checked-ring tail call are
-- LIVE. Passive gates CastSpell engine-side (spellbook.rs), not here — this only grays
-- the presentation.
function BenillaSpellButton_UpdateButton(button)
if not button:IsVisible() then
return
end
if GameTooltip:IsOwned(button) then
BenillaSpellButton_OnEnter(button)
end
if not SpellBookFrame.selectedSkillLine then
SpellBookFrame.selectedSkillLine = 1
end
local isPet = SpellBookFrame.bookType == BOOKTYPE_PET
local temp, tabTexture, offset, numSpells = GetSpellTabInfo(SpellBookFrame.selectedSkillLine)
offset = offset or 0
numSpells = numSpells or 0
SpellBookFrame.selectedSkillLineOffset = offset
local id = SpellBook_GetSpellID(button:GetID())
local name = button:GetName()
local iconTexture = getglobal(name .. "IconTexture")
local spellString = getglobal(name .. "SpellName")
local subSpellString = getglobal(name .. "SubSpellName")
local cooldown = getglobal(name .. "Cooldown")
local autocastable = getglobal(name .. "AutoCastable")
local highlightTexture = getglobal(name .. "Highlight")
local normalTexture = getglobal(name .. "NormalTexture")
-- TWO WAYS A SLOT COMES UP EMPTY, one dress for both. Past the end of the tab the
-- button also goes DEAD; a slot the book counts but has no icon for stays live and
-- only puts its highlight kit back to the square. Everything else the two do — the
-- five regions down, the autocast bit cleared, the ring unchecked, the button's own
-- ring back to plain white — is the same blanking, and it is written once here rather
-- than once per arm.
--
-- ref l.328: the out-of-tab test is `(bookType ~= BOOKTYPE_PET) and ...` — the pet
-- page has no tab to be out of, so a pet button past the end of the book falls to the
-- no-icon arm instead and stays ENABLED. Faithful, and harmless: an enabled button
-- with no spell behind it clicks into GetSpellName's nil and does nothing.
local outOfTab = (not isPet) and id > (offset + numSpells)
if outOfTab then
button:Disable()
else
button:Enable()
end
local texture
if not outOfTab then
texture = GetSpellTexture(id, SpellBookFrame.bookType)
end
if outOfTab or (not texture) or (texture == "") then
iconTexture:Hide()
spellString:Hide()
subSpellString:Hide()
cooldown:Hide()
autocastable:Hide()
button.shine:Hide()
if not outOfTab then
highlightTexture:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
end
button:SetChecked(0)
normalTexture:SetVertexColor(1.0, 1.0, 1.0)
return
end
-- The cooldown sweep + the enable-keyed icon dim (ref l.359-365): enable == 0 is an
-- on-hold cooldown (Stealth/Feign Death parked until SMSG_COOLDOWN_EVENT) — the
-- widget stays hidden (CooldownFrame_SetTimer's own gate) and the icon reads 40%.
local start, duration, enable = GetSpellCooldown(id, SpellBookFrame.bookType)
CooldownFrame_SetTimer(cooldown, start, duration, enable)
local brightness = (enable == 1) and 1.0 or 0.4
iconTexture:SetVertexColor(brightness, brightness, brightness)
-- ref l.367-377: the autocast pair. `GetSpellAutocast` answers (nil, nil) for the
-- player book by the engine's own short-circuit, so this needs no isPet guard — the
-- overlay and the sparkle simply never come on outside the pet page. Showing the
-- shine marker IS the sparkle (decision 1383 — the native lane draws at it).
local autoCastAllowed, autoCastEnabled = GetSpellAutocast(id, SpellBookFrame.bookType)
if autoCastAllowed then autocastable:Show() else autocastable:Hide() end
if autoCastEnabled then button.shine:Show() else button.shine:Hide() end
local spellName, subSpellName = GetSpellName(id, SpellBookFrame.bookType)
local isPassive = IsSpellPassive(id, SpellBookFrame.bookType)
-- A passive slot wears a whole different dress — the button ring blacked right out,
-- the round passive highlight in place of the square one, and the name in the passive
-- grey. Three values that always travel together, so they are chosen once and applied
-- once instead of each verb appearing in both arms.
local ringLevel, highlightFile, nameColor
if isPassive then
ringLevel, highlightFile, nameColor =
0, "Interface\\Buttons\\UI-PassiveHighlight", PASSIVE_SPELL_FONT_COLOR
else
ringLevel, highlightFile, nameColor =
1.0, "Interface\\Buttons\\ButtonHilight-Square", NORMAL_FONT_COLOR
end
normalTexture:SetVertexColor(ringLevel, ringLevel, ringLevel)
highlightTexture:SetTexture(highlightFile)
spellString:SetTextColor(nameColor.r, nameColor.g, nameColor.b)
iconTexture:SetTexture(texture)
spellString:SetText(spellName)
subSpellString:SetText(subSpellName)
if subSpellName and subSpellName ~= "" then
spellString:SetPoint("LEFT", button, "RIGHT", 4, 4)
else
spellString:SetPoint("LEFT", button, "RIGHT", 4, 2)
end
iconTexture:Show()
spellString:Show()
subSpellString:Show()
BenillaSpellButton_UpdateSelection(button) -- ref l.403: every full refresh re-reads the ring
end
-- ── Page turn (ref PrevPageButton_OnClick/NextPageButton_OnClick, l.406-434) ─────────────
--
-- DO NOT "FIX" THE KEY (decision 1032, the standing rule from 1030). The reference writes
-- `SPELLBOOK_PAGENUMBERS[selectedSkillLine]` on BOTH books, while `SpellBook_GetCurrentPage`
-- reads `SPELLBOOK_PAGENUMBERS["pet"]` for the pet one — a key nothing writes. So on the
-- pet page the arrows turn nothing, the page text says a number the page does not honour,
-- and the *spell* book's page counter moves behind your back. All three are the reference's
-- and all three are reproduced; `SpellBook_GetSpellID`'s note is the other half.
function BenillaSpellBookPrevPageButton_OnClick()
local pageNum = SpellBook_GetCurrentPage() - 1
SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] = pageNum
SpellBook_UpdatePageArrows()
SpellBookPageText:SetText(format(PAGE_NUMBER, pageNum))
BenillaUpdateSpells()
-- ref l.414-417: the pet page re-asserts its own title, because the spell book's page
-- text handler is shared and the title is not.
if SpellBookFrame.bookType == BOOKTYPE_PET then
SpellBookTitleText:SetText(SpellBookFrame.petTitle)
end
PlaySound("igAbiliityPageTurn")
end
function BenillaSpellBookNextPageButton_OnClick()
local pageNum = SpellBook_GetCurrentPage() + 1
SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] = pageNum
SpellBook_UpdatePageArrows()
SpellBookPageText:SetText(format(PAGE_NUMBER, pageNum))
BenillaUpdateSpells()
if SpellBookFrame.bookType == BOOKTYPE_PET then
SpellBookTitleText:SetText(SpellBookFrame.petTitle)
end
PlaySound("igAbiliityPageTurn")
end
-- ── The skill-line tabs (ref SpellBookSkillLineTab_OnClick, l.436-457) ───────────────────
function SpellBookSkillLineTab_OnClick(id)
local update
if not id then
update = 1
id = this:GetID()
end
SpellBookFrame.selectedSkillLine = id
local name, texture, offset, numSpells = GetSpellTabInfo(id)
SpellBookFrame.selectedSkillLineOffset = offset or 0
SpellBookFrame.selectedSkillLineNumSpells = numSpells or 0
SpellBook_UpdatePageArrows()
SpellBookFrame_Update()
SpellBookPageText:SetText(format(PAGE_NUMBER, SpellBook_GetCurrentPage()))
if update then
BenillaUpdateSpells()
end
end
-- ref SpellBook_GetSpellID (l.459-465). This is the book id the engine's own byte-verified
-- seam expects verbatim (`benilla-ui/src/script/spellbook.rs`'s module doc).
--
-- DO NOT "FIX" THE PET ARM (decision 1032, the standing rule from 1030). The reference's
-- pet branch is a bare `return id` — no tab offset, and **no page term** — so the pet page
-- always shows book entries 1..12 no matter what page number the window is displaying. A
-- well-trained hunter pet can know more than twelve spells, so this is visible, and it is
-- still the reference's behaviour. Its sibling quirk is in the two page-turn handlers.
function SpellBook_GetSpellID(id)
if SpellBookFrame.bookType == BOOKTYPE_PET then
return id
end
return id + (SpellBookFrame.selectedSkillLineOffset or 0)
+ (SPELLS_PER_PAGE * ((SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] or 1) - 1))
end
-- ref SpellBook_UpdatePageArrows (l.467-479). An arrow is live exactly while the page it
-- points at is a DIFFERENT page. Stated as an inequality rather than a range test on
-- purpose: the pet book's arrows write a counter its own page ignores (the do-not-fix
-- notes above), so a page number can sit outside 1..maxPages, and when it does BOTH arrows
-- stay live — which is the reference's answer, not an accident of this spelling.
function SpellBook_UpdatePageArrows()
local currentPage, maxPages = SpellBook_GetCurrentPage()
if currentPage ~= 1 then SpellBookPrevPageButton:Enable() else SpellBookPrevPageButton:Disable() end
if currentPage ~= maxPages then SpellBookNextPageButton:Enable() else SpellBookNextPageButton:Disable() end
end
-- ref SpellBook_GetCurrentPage (l.481-493), both branches. `maxPages` is clamped to at
-- least 1 (a defensive addition this file's own — an empty/not-yet-fed book must still
-- show ONE (empty) page rather than disabling both page buttons into a currentPage=1 >
-- maxPages=0 dead state).
--
-- The pet branch reads `SPELLBOOK_PAGENUMBERS["pet"]`, which is the counter NOTHING ever
-- writes — see the page-turn handlers. So it is always 1, and with it `maxPages > 1` the
-- Next arrow stays enabled forever on a big pet book. Reference behaviour; see the
-- do-not-fix note on `SpellBook_GetSpellID`.
function SpellBook_GetCurrentPage()
local numPetSpells = HasPetSpells()
if numPetSpells and SpellBookFrame.bookType == BOOKTYPE_PET then
return SPELLBOOK_PAGENUMBERS["pet"] or 1,
max(ceil(numPetSpells / SPELLS_PER_PAGE), 1)
end
local currentPage = SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] or 1
local temp, texture, offset, numSpells = GetSpellTabInfo(SpellBookFrame.selectedSkillLine)
local maxPages = max(ceil((numSpells or 0) / SPELLS_PER_PAGE), 1)
return currentPage, maxPages
end
]]></Script>
<!--
The skill-line tab (ref SpellBookSkillLineTabTemplate, l.4-39): 32x32, the
SpellBook-SkillLineTab 64x64 backdrop offset (-3,11), an empty NormalTexture placeholder
(filled at runtime by SetNormalTexture — SetNormalTexture creates the region lazily on
first call, `benilla-ui/src/script/button.rs`'s `ensure_slot`, verified this session), the
ADD highlight + CheckedTexture ring for the SELECTED tab.
-->
<CheckButton name="BenillaSpellBookSkillLineTabTemplate" virtual="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\SpellBook\SpellBook-SkillLineTab">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="-3" y="11"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<NormalTexture/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnClick>SpellBookSkillLineTab_OnClick()</OnClick>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
if self.tooltip then
GameTooltip:SetText(self.tooltip)
GameTooltip:Show()
end
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</CheckButton>
<!--
The spell button (ref SpellButtonTemplate, l.74-201): 37x37, the UI-Spellbook-SpellBackground
64x64 backdrop offset (-3,3), the icon (setAllPoints, hidden until fed), the name/rank
FontStrings anchored off the button's own RIGHT edge, the UI-Quickslot2 ring + Depress push +
ADD highlight + the CheckedTexture ring (`IsCurrentCast`'s glow), and the Cooldown child
(ref l.136: $parentCooldown inherits CooldownFrameTemplate — ours is the native Cooldown
widget at the template's own 36x36 CENTER (0,-1), the ActionBar/BagFrame transcription).
The AutoCast overlay/model is OMITTED (this file's header comment — pet-only autocast).
-->
<CheckButton name="BenillaSpellButtonTemplate" virtual="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBackground" file="Interface\Spellbook\UI-Spellbook-SpellBackground">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="-3" y="3"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<!-- The corner brackets that say a pet spell CAN autocast. The art fills only the
middle 33/64 = 0.5156 of UI-AutoCastableOverlay.blp, so where the brackets land
is set entirely by how big the texture is drawn:
pet button ref 58 -> 58 x 0.5156 = 29.9 on a 30 button. Dead in the corners.
spell book ref 60 -> 60 x 0.5156 = 30.9 on a 37 button. THREE UNITS INSIDE
each edge, floating — the ref barely grew the overlay (58->60)
while the button grew 30->37.
DEVIATION 2 of 3 in this widget (decision 1393, extending 1392). 71.53 is the
pet button's own ratio carried over — 37 x 58/30 — so the bracket square lands
on this button's corners exactly as it does on that one. Write 60 to go back to
the reference. -->
<Texture name="$parentAutoCastable" file="Interface\Buttons\UI-AutoCastableOverlay" hidden="true">
<Size><AbsDimension x="71.53" y="71.53"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Texture>
<!-- The animated sibling: the ref's $parentAutoCast <Model>, which on this
template carries its own 36x36 CENTER (1,1) rect at scale="1.22".
DEVIATION 3 of 3 (decision 1393, extending 1392), and both halves are the same
idea — borrow the pet button's proportions so this button reads like that one:
* rect 36 CENTER (1,1) -> 37 CENTER (0,0). The ref's +1,+1 puts the glow a
unit up-and-right of the brackets, which sit dead centre; measured on the
director's capture, the glow's centre really was ~1 unit right of the
button's. Concentric now, so top/right stop reading loose.
* scale 1.22 -> 1.48. 0.02 x 1280 x 1.48 = 37.888 = 1.024 x 37 — the same
1.024 rim/viewport ratio the ref writes on the pet button, which is what
puts the rim ON the viewport edge so the widget's own scissor halves every
star (1387). At the ref's 0.87x the rim floats clear and nothing clips it.
To go back to the reference exactly: 36x36, CENTER (1,1), and 1.22. -->
<Texture name="$parentShine" file="benilla:autocast-shine:1.48" hidden="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="BORDER">
<Texture name="$parentIconTexture" setAllPoints="true" hidden="true"/>
<!-- maxLines="3" unmodeled (this file's header comment) — wraps unbounded instead. -->
<FontString name="$parentSpellName" inherits="GameFontNormal" hidden="true" justifyH="LEFT">
<Size><AbsDimension x="103" y="0"/></Size>
<Anchors>
<Anchor point="LEFT" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentSubSpellName" inherits="SubSpellFont" hidden="true" justifyH="LEFT">
<Size><AbsDimension x="79" y="18"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentSpellName" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="0" y="4"/></Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Cooldown name="$parentCooldown" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</Cooldown>
</Frames>
<Scripts>
<OnLoad>SpellButton_OnLoad()</OnLoad>
<OnEvent>SpellButton_OnEvent(event)</OnEvent>
<OnClick>SpellButton_OnClick()</OnClick>
<OnShow>BenillaSpellButton_UpdateButton(self)</OnShow>
<!-- The reference routes both drag entries to the plain click path with drag=1
(SpellButtonTemplate l.169-174) — the bag/doll/action slots' own precedent. -->
<OnDragStart>SpellButton_OnClick(1)</OnDragStart>
<OnReceiveDrag>SpellButton_OnClick(1)</OnReceiveDrag>
<OnEnter>BenillaSpellButton_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<NormalTexture name="$parentNormalTexture" file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Quickslot-Depress"/>
<HighlightTexture name="$parentHighlight" file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
</CheckButton>
<!--
The Spell/Pet toggle tab (ref SpellBookFrameTabButtonTemplate, l.40-71): a 128x64 plate
that hangs off the window's bottom-left, drawn UNSELECTED normally and swapping to the
selected plate through its <DisabledTexture> — the ref uses Disable() for "this is the
book you are on", not a checked state, which is why this is a plain Button and not a
CheckButton. Hidden by default; SpellBookFrame_Update raises exactly the ones that exist.
-->
<Button name="BenillaSpellBookFrameTabButtonTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="128" y="64"/></Size>
<HitRectInsets><AbsInset left="15" right="14" top="13" bottom="15"/></HitRectInsets>
<Scripts>
<OnClick>ToggleSpellBook(self.bookType)</OnClick>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
-- ref l.53: the label plus its bound key. MicroButtonTooltipText is MicroMenu.xml's
-- (loaded before this file); nil-guarded like every other cross-file call here.
if MicroButtonTooltipText then
GameTooltip:SetText(MicroButtonTooltipText(self:GetText(), self.binding), 1.0, 1.0, 1.0)
else
GameTooltip:SetText(self:GetText(), 1.0, 1.0, 1.0)
end
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<ButtonText>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormalSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<NormalTexture file="Interface\SpellBook\UI-SpellBook-Tab-Unselected"/>
<HighlightTexture file="Interface\SpellBook\UI-SpellbookPanel-Tab-Highlight" alphaMode="ADD"/>
</Button>
<Frame name="SpellBookFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
<!-- The real 384x512 window (ref l.202-205). No <Anchors> — ShowUIPanel/SetLeftFrame
SetPoints this to the left slot on open (UiPanels.xml's own convention). -->
<Size><AbsDimension x="384" y="512"/></Size>
<!-- HitRectInsets: transcribed for fidelity; no loader wiring exists yet (CharacterFrame's
own precedent note) — a harmless no-op today. -->
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="70"/></HitRectInsets>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\Spellbook\Spellbook-Icon">
<Size><AbsDimension x="58" y="58"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-8"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The four quadrant plates, anchored off the frame's own corners exactly like
the ref (l.231-263) — simpler than CharacterFrame's TOPLEFT-offset scheme
since the ref itself uses the corner anchors directly here. -->
<Texture file="Interface\Spellbook\UI-SpellbookPanel-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\Spellbook\UI-SpellbookPanel-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
</Texture>
<Texture file="Interface\Spellbook\UI-SpellbookPanel-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture file="Interface\Spellbook\UI-SpellbookPanel-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
<FontString name="SpellBookTitleText" inherits="GameFontNormal" text="SPELLBOOK">
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="6" y="230"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="SpellBookPageText" inherits="GameFontNormal">
<Size><AbsDimension x="102" y="0"/></Size>
<Anchors>
<Anchor point="BOTTOM"><Offset><AbsDimension x="-14" y="96"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- Close: the house minimize-button art (no UIPanelCloseButton template ships —
Gossip/Bag/Merchant/CharacterFrame's own precedent), the same TOPRIGHT(-44,-25)
position as every other window's close button. -->
<Button name="SpellBookFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-44" y="-25"/></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>HideUIPanel(SpellBookFrame)</OnClick>
</Scripts>
</Button>
<!-- ref l.288-317. Tab 3 exists in the ref's own XML and is never raised in 1.12
(there is no third book); kept so the anchor chain reads like the ref's. -->
<Button name="SpellBookFrameTabButton1" inherits="BenillaSpellBookFrameTabButtonTemplate">
<Anchors>
<Anchor point="CENTER" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="79" y="61"/></Offset></Anchor>
</Anchors>
<DisabledTexture file="Interface\SpellBook\UI-SpellBook-Tab1-Selected"/>
</Button>
<Button name="SpellBookFrameTabButton2" inherits="BenillaSpellBookFrameTabButtonTemplate">
<Anchors>
<Anchor point="LEFT" relativeTo="SpellBookFrameTabButton1" relativePoint="RIGHT">
<Offset><AbsDimension x="-20" y="0"/></Offset>
</Anchor>
</Anchors>
<DisabledTexture file="Interface\SpellBook\UI-SpellBook-Tab1-Selected"/>
</Button>
<Button name="SpellBookFrameTabButton3" inherits="BenillaSpellBookFrameTabButtonTemplate">
<Anchors>
<Anchor point="LEFT" relativeTo="SpellBookFrameTabButton2" relativePoint="RIGHT">
<Offset><AbsDimension x="-20" y="0"/></Offset>
</Anchor>
</Anchors>
<DisabledTexture file="Interface\SpellBook\UI-SpellBook-Tab3-Selected"/>
</Button>
<Button name="SpellBookPrevPageButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="SpellBookFrame" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="50" y="105"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString inherits="GameFontNormal" justifyH="LEFT" text="PREV">
<Anchors><Anchor point="LEFT" relativeTo="SpellBookPrevPageButton" relativePoint="RIGHT"/></Anchors>
</FontString>
</Layer>
</Layers>
<Scripts><OnClick>BenillaSpellBookPrevPageButton_OnClick()</OnClick></Scripts>
<NormalTexture file="Interface\Buttons\UI-SpellbookIcon-PrevPage-Up"/>
<PushedTexture file="Interface\Buttons\UI-SpellbookIcon-PrevPage-Down"/>
<DisabledTexture file="Interface\Buttons\UI-SpellbookIcon-PrevPage-Disabled"/>
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" alphaMode="ADD"/>
</Button>
<Button name="SpellBookNextPageButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="SpellBookFrame" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="314" y="105"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString inherits="GameFontNormal" justifyH="RIGHT" text="NEXT">
<Anchors><Anchor point="RIGHT" relativeTo="SpellBookNextPageButton" relativePoint="LEFT"/></Anchors>
</FontString>
</Layer>
</Layers>
<Scripts><OnClick>BenillaSpellBookNextPageButton_OnClick()</OnClick></Scripts>
<NormalTexture file="Interface\Buttons\UI-SpellbookIcon-NextPage-Up"/>
<PushedTexture file="Interface\Buttons\UI-SpellbookIcon-NextPage-Down"/>
<DisabledTexture file="Interface\Buttons\UI-SpellbookIcon-NextPage-Disabled"/>
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" alphaMode="ADD"/>
</Button>
<!-- The 12-spell page grid (ref l.387-494): 2 columns x 6 rows, ids assigned the
ref's own column-major pattern (col1 ids 1-6, col2 ids 7-12) — SpellBook_GetSpellID
reads a button's `id=` as its within-page/within-tab 1-based position, not its
screen order. -->
<CheckButton name="SpellButton1" inherits="BenillaSpellButtonTemplate" id="1">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="34" y="-85"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton2" inherits="BenillaSpellButtonTemplate" id="7">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton1"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton3" inherits="BenillaSpellButtonTemplate" id="2">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton4" inherits="BenillaSpellButtonTemplate" id="8">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton3"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton5" inherits="BenillaSpellButtonTemplate" id="3">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton6" inherits="BenillaSpellButtonTemplate" id="9">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton5"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton7" inherits="BenillaSpellButtonTemplate" id="4">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton8" inherits="BenillaSpellButtonTemplate" id="10">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton7"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton9" inherits="BenillaSpellButtonTemplate" id="5">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton10" inherits="BenillaSpellButtonTemplate" id="11">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton9"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton11" inherits="BenillaSpellButtonTemplate" id="6">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton9" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton12" inherits="BenillaSpellButtonTemplate" id="12">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton11"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<!-- The right-edge skill-line tabs, up to MAX_SKILLLINE_TABS (8) — ref l.495-566. -->
<CheckButton name="SpellBookSkillLineTab1" inherits="BenillaSpellBookSkillLineTabTemplate" id="1">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-32" y="-65"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab2" inherits="BenillaSpellBookSkillLineTabTemplate" id="2">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab3" inherits="BenillaSpellBookSkillLineTabTemplate" id="3">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab4" inherits="BenillaSpellBookSkillLineTabTemplate" id="4">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab5" inherits="BenillaSpellBookSkillLineTabTemplate" id="5">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab6" inherits="BenillaSpellBookSkillLineTabTemplate" id="6">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab7" inherits="BenillaSpellBookSkillLineTabTemplate" id="7">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab8" inherits="BenillaSpellBookSkillLineTabTemplate" id="8">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
</Frames>
<Scripts>
<OnLoad>SpellBookFrame_OnLoad()</OnLoad>
<OnEvent>SpellBookFrame_OnEvent()</OnEvent>
<OnShow>SpellBookFrame_OnShow()</OnShow>
<OnHide>SpellBookFrame_OnHide()</OnHide>
</Scripts>
</Frame>
</Ui>
-374
View File
@@ -1,374 +0,0 @@
<Ui>
<!--
The stance/shapeshift bar (vanilla ShapeshiftBarFrame, ref-BonusActionBarFrame.xml/.lua —
extracted from patch.MPQ). Warrior stances, druid forms, rogue Stealth: one CheckButton per
known form, checked = the ACTIVE form (the descriptor's UNIT_FIELD_BYTES_1 byte-2 form,
wow-re bonusbar-shapeshift.md), grey icon when not castable, cooldown swipe, click casts
the form (`crate::ui_shapeshift` drains CastShapeshiftForm onto the wire).
Geometry QUOTED from the ref's ShapeshiftBar_UpdatePosition (BonusActionBarFrame.lua
l.229-251), which forks on whether MultiBarBottomLeft is up: buttons 30x30, button1 at frame
BOTTOMLEFT +(11,3), chained +7; NormalTexture UI-Quickslot2 at the RAISED mode's 50x50 (ref
l.236-237), CENTER (0,-1) like the action template. Max 10 slots (NUM_SHAPESHIFT_SLOTS).
THE SEAT IS NOT THIS FILE'S — the XML anchor below (MainMenuBar TOPLEFT +(30,45)) is only
the value the manage pass computes with the bottom-left bar up, written statically so the
frame is somewhere sane before the first pass runs. `ShapeshiftBarFrame` is a managed row
(UIParent.xml: baseY 0, bottomLeft 45, xOffset 30), so the pass re-derives +45 raised and
+0 unraised — the ref's own two positions — and re-seats the frame on every bar change. That
used to be a distinction without a difference (0270's bottom bars were always on, so raised
was the only state); decision 1500 made all four extra bars player options, and the unraised
state is now reachable. It is covered, and `multibar_stance_tests` pins both seats.
The ART half of that same fork IS this file's, and it is `ShapeshiftBar_UpdatePosition` in
the script below: the ref's unraised branch also shows the ShapeshiftBarEnds/Middle shelf
strips and grows each button's ring from 50x50 to 64x64. That branch was dead code while the
bottom bars were always on, and 1500 made it the DEFAULT — every form class meets the
unraised bar on a fresh character — so a shelf-less bar would be the shipped look rather than
an edge case. It rides `UIParent_RegisterManagedPositionListener` (decision 1499's
after-every-recompute hook, the same one the bag stack uses) so the art settles when the pass
that decides raised-ness has finished, and it needs no edit to UIParent.xml.
Ref divergences (deliberate):
- ONE refresh event: the app diffs the whole pushed form list (identity, active, castable,
cooldown) and fires UPDATE_SHAPESHIFT_FORMS on any change — so the ref's separate
Update/UpdateState split over six events (SPELL_UPDATE_COOLDOWN/_USABLE,
PLAYER_AURAS_CHANGED, UPDATE_INVENTORY_ALERTS "Wha??", ACTIONBAR_PAGE_CHANGED) collapses
into Update on UPDATE_SHAPESHIFT_FORMS + PLAYER_ENTERING_WORLD.
- No lastSelected bookkeeping (ref ShapeshiftBar_ChangeForm) — checked state is always
re-derived from the pushed `isActive`, never locally latched: the server's form byte is
the truth and the app re-pushes on its every change. (Verified dead in the ref itself:
`lastSelected` is written twice and read nowhere in the shipped FrameXML.)
-->
<Script><![CDATA[
NUM_SHAPESHIFT_SLOTS = 10
-- RIGHT-CLICK IS DEAD HERE, and deliberately (decision 1030 reverting 1027). The ref's
-- ShapeshiftButtonTemplate registers NOTHING: it inherits ActionButtonTemplate and then
-- overrides <OnLoad> with a body that only scales the cooldown, so the
-- `RegisterForClicks("LeftButtonUp","RightButtonUp")` that ActionButton_OnLoad gives every
-- other action-style button (ref ActionButton.lua:109) is lost and the default
-- {LeftButtonUp} stands. A right-press therefore neither flashes the pushed art
-- (0x77924b gates that on the same mask) nor casts. It reads like a Blizzard oversight
-- and it is the only bar that behaves this way — reproduced anyway, because the director
-- checked it against the reference and called faithful.
function BenillaShapeshiftButton_OnLoad(button, index)
button.index = index
button.icon = getglobal(button:GetName() .. "Icon")
button.cooldown = getglobal(button:GetName() .. "Cooldown")
end
-- The CheckButton widget toggles itself BEFORE OnClick fires (the widget contract); the
-- ref immediately reverts that toggle (ShapeshiftButtonTemplate OnClick, ref-
-- BonusActionBarFrame.xml l.31-38), so the checked ring never follows the click — only
-- the isActive repaint. That is what keeps a warrior's ACTIVE stance lit through the
-- click's silent wire no-op (stances never cancel — the 0x4b4963 guard), and a druid
-- form/Stealth lit until the server actually confirms the shift-out. The revert is
-- unconditional, which is why no click here can leave a lit form dark — unlike the pet
-- bar's flat SetChecked(0), which needs a repaint to be correct.
function BenillaShapeshiftButton_OnClick(button)
if button:GetChecked() then
button:SetChecked(nil)
else
button:SetChecked(1)
end
CastShapeshiftForm(button.index)
end
-- Hover: the form's spell tooltip through the engine channel (ref ShapeshiftBar's
-- GameTooltip:SetShapeshift; decision 0274 P2). Anchor: the ref's ShapeshiftButton
-- OnEnter (BonusActionBarFrame.xml l.40-45) takes the default corner when UberTooltips
-- is "1" — its byte-verified stock default — and sits beside the button otherwise. Like
-- the action bar's, that fork was collapsed to the "1" leg until B230 registered the CVar
-- (cvars.rs carries the byte-read); the stock look is unchanged, the off leg is reachable.
function BenillaShapeshiftButton_OnEnter(button)
if not GameTooltip then return end -- engine tests load file subsets
if GetCVar("UberTooltips") == "0" then
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
else
GameTooltip_SetDefaultAnchor(GameTooltip, button)
end
GameTooltip:SetShapeshift(button.index)
end
function BenillaShapeshiftButton_OnLeave(button)
GameTooltip:Hide()
end
-- Ref ShapeshiftBar_Update + ShapeshiftBar_UpdateState fused (SCOPE note above): size the
-- bar to the form count (hide the whole frame at 0 — a formless class never shows it),
-- then paint each button — icon, checked (active), the 1.0/0.4 castable tint (ref l.210-214),
-- cooldown swipe (CooldownFrame_SetTimer, Cooldown.xml's shared transcription).
function ShapeshiftBar_Update()
local numForms = GetNumShapeshiftForms()
if numForms > 0 then
-- ── The shelf's LENGTH is the form count (ref l.161-175) ─────────────────────
-- The three strips are DECLARED as the >2-form chain (Left → Middle → Right), so
-- the one- and two-form shelves have to re-point the end cap. Hiding the middle
-- strip does NOT close its gap — a hidden region still resolves its rect, here
-- and in the reference — so without this the 42x50 right cap sits 38px past the
-- last button as a stray empty recess: a rogue's Stealth with a second, always-
-- empty slot beside it. One form pulls the cap back INSIDE the left cap (+12, a
-- 54px shelf around the single button); two butt the caps together; past two the
-- middle strip carries one slot per extra form and the cap chains off it.
if numForms == 1 then
ShapeshiftBarRight:SetPoint("LEFT", ShapeshiftBarLeft, "LEFT", 12, 0)
elseif numForms == 2 then
ShapeshiftBarRight:SetPoint("LEFT", ShapeshiftBarLeft, "RIGHT", 0, 0)
else
ShapeshiftBarMiddle:SetPoint("LEFT", ShapeshiftBarLeft, "RIGHT", 0, 0)
ShapeshiftBarMiddle:SetWidth(38 * (numForms - 2))
ShapeshiftBarMiddle:SetTexCoord(0, numForms - 2, 0, 1)
ShapeshiftBarRight:SetPoint("LEFT", ShapeshiftBarMiddle, "RIGHT", 0, 0)
end
ShapeshiftBarFrame:Show()
else
ShapeshiftBarFrame:Hide()
end
for i = 1, NUM_SHAPESHIFT_SLOTS do
local button = getglobal("ShapeshiftButton" .. i)
if i <= numForms then
local texture, name, isActive, isCastable = GetShapeshiftFormInfo(i)
button.icon:SetTexture(texture or BENILLA_FALLBACK_ICON)
local start, duration, enable = GetShapeshiftFormCooldown(i)
CooldownFrame_SetTimer(button.cooldown, start, duration, enable)
if isActive then
button:SetChecked(1)
else
button:SetChecked(0)
end
if isCastable then
button.icon:SetVertexColor(1.0, 1.0, 1.0)
else
button.icon:SetVertexColor(0.4, 0.4, 0.4)
end
button:Show()
else
button:Hide()
end
end
-- The art depends on the form COUNT too (the middle strip's `> 2`), so it is re-derived
-- here as well as on every managed-position pass. Pure derivation, so running it twice
-- costs nothing and missing it costs a strip.
ShapeshiftBar_UpdatePosition()
end
-- ── The raised/unraised ART fork (ref ShapeshiftBar_UpdatePosition, l.229-251) ──────────
-- The reference's function does TWO things and we only owe it one. Its two SetPoints —
-- MainMenuBar TOPLEFT +(30,45) with the bottom-left multibar up, +(30,0) without — are
-- already `UIParent_ManageFramePositions`' job here: `ShapeshiftBarFrame` is a managed row
-- (baseY 0, bottomLeft 45, xOffset 30), which IS those two positions, and a second
-- SetPoint from this file would be a competing answer to a question already answered.
-- What the pass does NOT carry is the art half, and that is this:
--
-- raised — the three shelf strips hidden, each button's ring 50x50. The shelf art IS
-- the bar's border and only reads as one while the bar sits on the main bar;
-- a row up it would draw across whatever is underneath (the same rule the
-- pass applies to the pet bar's sliding strips).
-- unraised — the two END-CAPS shown, the MIDDLE strip shown only past two forms, each
-- button's ring 64x64.
--
-- The `> 2` on the middle strip is the ref's own and easy to miss: a two-form class (a
-- priest with Shadowform, a warrior before Berserker) gets the end caps and no middle.
--
-- **This became load-bearing with 1500.** While the bottom bars were always on (0270) the
-- raised branch was the only reachable state and this whole fork was dead code; now every
-- form class sees the UNRAISED bar on a fresh character, so the shelf-less version would be
-- the shipped default look rather than an edge case.
--
-- It keeps the reference's NAME because the corpus replaces that name wholesale:
-- CT_BottomBar.lua:712/741 saves and overwrites `ShapeshiftBar_UpdatePosition` to take the
-- bar over. The listener below therefore calls it through the GLOBAL, so an addon that
-- replaces it really does take the art with it.
function ShapeshiftBar_UpdatePosition()
local raised = MultiBarBottomLeft and MultiBarBottomLeft:IsShown()
local ring = 64
if raised then
ring = 50
ShapeshiftBarLeft:Hide()
ShapeshiftBarMiddle:Hide()
ShapeshiftBarRight:Hide()
else
ShapeshiftBarLeft:Show()
ShapeshiftBarRight:Show()
if GetNumShapeshiftForms() > 2 then
ShapeshiftBarMiddle:Show()
else
ShapeshiftBarMiddle:Hide()
end
end
-- The ref sizes only the buttons a form occupies; ours sizes all ten. Same pixels (a
-- hidden button draws nothing) and one less thing to get wrong — a button that appears
-- later is already the right size, without this needing to run again for it.
for i = 1, NUM_SHAPESHIFT_SLOTS do
local ringTexture = getglobal("ShapeshiftButton" .. i .. "NormalTexture")
ringTexture:SetWidth(ring)
ringTexture:SetHeight(ring)
end
end
function ShapeshiftBar_OnLoad()
local frame = this
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("UPDATE_SHAPESHIFT_FORMS")
ShapeshiftBar_Update()
end
function ShapeshiftBar_OnEvent()
local frame = this
ShapeshiftBar_Update()
end
-- Re-derive the art after every managed-position recompute (decision 1499's hook — the same
-- after-the-pass seam the bag stack registers on). The pass is what decides whether this bar
-- is raised, so the art fork has to run when the pass has finished and not before. Called
-- through the global name so an addon replacement is honoured (see the function's comment).
-- Guarded for the harnesses that load this file without UIParent.xml, the way BagFrame.xml
-- guards its own registration.
if UIParent_RegisterManagedPositionListener then
UIParent_RegisterManagedPositionListener(function() ShapeshiftBar_UpdatePosition() end)
end
]]></Script>
<!--
One stance button (ref ShapeshiftButtonTemplate: 30x30, inherits ActionButtonTemplate's
art set): icon fills (BACKGROUND); Cooldown child (the ref SetScale(.83)s the 36px
template's — ours is sized 30x30 directly); the raised-mode 50x50 UI-Quickslot2 ring
CENTER (0,-1); the action template's pushed/highlight/checked trio.
-->
<CheckButton name="BenillaShapeshiftButtonTemplate" virtual="true">
<Size><AbsDimension x="30" y="30"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon"/>
</Layer>
</Layers>
<Frames>
<Cooldown name="$parentCooldown" hidden="true">
<Size><AbsDimension x="30" y="30"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</Cooldown>
</Frames>
<NormalTexture name="$parentNormalTexture" file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="50" y="50"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="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"/>
<Scripts>
<OnClick>BenillaShapeshiftButton_OnClick(self)</OnClick>
<OnEnter>BenillaShapeshiftButton_OnEnter(self)</OnEnter>
<OnLeave>BenillaShapeshiftButton_OnLeave(self)</OnLeave>
</Scripts>
</CheckButton>
<Frame name="ShapeshiftBarFrame" enableMouse="true" parent="MainMenuBar" hidden="true">
<Size><AbsDimension x="29" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuBar" relativePoint="TOPLEFT"><Offset><AbsDimension x="30" y="45"/></Offset></Anchor>
</Anchors>
<!-- The shelf art (ref BonusActionBarFrame.xml l.220-249), declared HIDDEN.
The header above says this art "is not modeled at all" because raised mode hides it and
we are permanently raised. That reasoning was right about the PIXELS and wrong about
the NAMES: 1219/1223 settled that the reference does not choose between shown and
absent, and "we don't show it" is not a reason for it not to exist. Four frames landed
on that principle; these are the first REGIONS to, which is why the sweep that closed
the class missed them — it ran over the frames/tables ranking, and a Texture is
neither.
Two corpus addons index them, and both do pure LAYOUT — they do not use the art, they
move it, exactly the shape 1223 found for BonusActionBarFrame:
zBarEx/zMainBar.lua:288-290 SetParent(MainMenuBar) on all three
CT_BottomBar.lua:681-692 ClearAllPoints + SetPoint off-screen
Absent, `ShapeshiftBarLeft:ClearAllPoints()` is `attempt to index global` and takes the
addon's whole handler with it.
Geometry, anchor chain and TexCoords quoted whole: Left and Right are two crops of ONE
`ShapeshiftBarEnds` file (0..0.453125 and 0.453125..0.875), Middle its own, and Right
hangs off Middle's RIGHT — the >2-form chain, which is the DECLARED state only. The
cap is re-pointed per form count by `ShapeshiftBar_Update` above (the ref's l.161-175):
hiding the middle strip leaves its 38px rect behind, so the declared chain alone
strands the cap past the last button.
`hidden="true"` is the RAISED state, and it is only the pre-pass value now:
`ShapeshiftBar_UpdatePosition` (above) shows and hides all three from the bottom-left
multibar's state on every managed-position pass. Declared hidden rather than shown so a
harness that never runs the pass draws nothing new. -->
<Layers>
<Layer level="OVERLAY">
<Texture name="ShapeshiftBarLeft" file="Interface\ShapeshiftBar\ShapeshiftBarEnds" hidden="true">
<Size><AbsDimension x="45" y="50"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
<TexCoords left="0" right="0.453125" top="0" bottom="1"/>
</Texture>
<Texture name="ShapeshiftBarMiddle" file="Interface\ShapeshiftBar\ShapeshiftBarMiddle" hidden="true">
<Size><AbsDimension x="38" y="50"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftBarLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="1" top="0" bottom="1"/>
</Texture>
<Texture name="ShapeshiftBarRight" file="Interface\ShapeshiftBar\ShapeshiftBarEnds" hidden="true">
<Size><AbsDimension x="42" y="50"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftBarMiddle" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.453125" right="0.875" top="0" bottom="1"/>
</Texture>
</Layer>
</Layers>
<Frames>
<CheckButton name="ShapeshiftButton1" inherits="BenillaShapeshiftButtonTemplate" id="1">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="11" y="3"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 1)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton2" inherits="BenillaShapeshiftButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton1" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 2)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton3" inherits="BenillaShapeshiftButtonTemplate" id="3">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton2" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 3)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton4" inherits="BenillaShapeshiftButtonTemplate" id="4">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton3" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 4)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton5" inherits="BenillaShapeshiftButtonTemplate" id="5">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton4" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 5)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton6" inherits="BenillaShapeshiftButtonTemplate" id="6">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton5" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 6)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton7" inherits="BenillaShapeshiftButtonTemplate" id="7">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton6" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 7)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton8" inherits="BenillaShapeshiftButtonTemplate" id="8">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton7" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 8)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton9" inherits="BenillaShapeshiftButtonTemplate" id="9">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton8" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 9)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton10" inherits="BenillaShapeshiftButtonTemplate" id="10">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton9" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 10)</OnLoad></Scripts>
</CheckButton>
</Frames>
<Scripts>
<OnLoad>ShapeshiftBar_OnLoad()</OnLoad>
<OnEvent>ShapeshiftBar_OnEvent()</OnEvent>
<!-- The stance bar is the pet/stance term of the managed bottom-stack positions
(UIParent.xml, decision 0272): its show/hide re-fires the pass, exactly the
ref's BonusActionBarFrame.xml:350-355. -->
<OnShow>UIParent_ManageFramePositions()</OnShow>
<OnHide>UIParent_ManageFramePositions()</OnHide>
</Scripts>
</Frame>
</Ui>
@@ -1,82 +0,0 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The class trainer's "unlearn all of your talents?" dialog (decision 1580): a registry entry
on the shared StaticPopup engine (UiPanels.xml) plus its UIParent event arm, written against
the patch-chain FrameXML (mpqx extraction, decision 0246) — StaticPopup.lua l.1289-1304 (the
entry) and UIParent.lua l.123 / l.533-538 (the registration and the show arm). The string is
GlobalStrings.lua l.853, verbatim.
It is BinderConfirm.xml's twin, one line above it in the TOC, and free-standing for the
identical reason: selecting the trainer's "I wish to unlearn my talents" line makes the
server CLOSE the gossip menu and then ask, so the window that raised this dialog is already
gone by the time it appears.
The whole flow behind it is one two-way opcode — MSG_TALENT_WIPE_CONFIRM in with the
trainer's guid and the cost, the same opcode back out with that guid — and only the second
one unlearns anything (`crate::ui_talent_wipe`'s header carries the law). Accepting is
therefore the ONLY path to a reset spec; there is nothing to undo on Cancel, which is why
this entry has no OnCancel.
It is also the first entry in the registry to raise a MONEY frame, because the cost is the
interesting half of the question and the sentence below deliberately does not name it — the
engine shows the coin row for `hasMoneyFrame`, and the arm at the bottom fills it.
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim) ───────────────────────────────────────────────
local CONFIRM_TALENT_WIPE =
"Do you want to unlearn all of your talents? The cost will increase each time you do it." -- l.853
local ACCEPT = "Accept" -- l.10
local CANCEL = "Cancel" -- l.464
-- The dialog's only teardown, and like the innkeeper's it is a poll rather than an event:
-- walk away from the trainer and the question leaves the screen by itself, with no packet
-- either way. `CheckTalentMasterDist()` is the engine binding behind it
-- (`benilla-ui/src/script/talent.rs`), false as soon as the app's NPC-session guard has
-- closed the question. Named rather than inlined, because "the question expires when you
-- walk away" is the interesting half of this file.
local function hideOnceOutOfRange(_, dialog)
if not CheckTalentMasterDist() then
dialog:Hide()
end
end
-- ── The confirm — answering to StaticPopup.lua l.1289-1304 ─────────────────────────────
StaticPopupDialogs["CONFIRM_TALENT_WIPE"] = {
text = CONFIRM_TALENT_WIPE,
button1 = ACCEPT,
button2 = CANCEL,
OnAccept = function()
ConfirmTalentWipe()
end,
OnUpdate = hideOnceOutOfRange,
hasMoneyFrame = 1,
timeout = 0,
hideOnEscape = 1,
}
-- ── The UIParent event arm (UIParent.lua l.533-538) ────────────────────────────────────
-- arg1 is the cost in COPPER, and it goes to the money frame rather than into the text:
-- the sentence above is fixed. The nil guard is the reference's own — Show returns nothing
-- when the dialog is refused (dead without whileDead, or no free instance), and there is
-- then no money frame to fill.
function BenillaTalentWipeConfirmDriver_OnEvent()
if event == "CONFIRM_TALENT_WIPE" then
local dialog = StaticPopup_Show("CONFIRM_TALENT_WIPE")
if dialog then
MoneyFrame_Update(dialog:GetName() .. "MoneyFrame", arg1)
end
end
end
]]></Script>
<Frame name="BenillaTalentWipeConfirmDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("CONFIRM_TALENT_WIPE")
</OnLoad>
<OnEvent>BenillaTalentWipeConfirmDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
-573
View File
@@ -1,573 +0,0 @@
<Ui>
<!--
benilla's player-to-player trade window (decision 0592 P1). The geometry/art/texcoords are
transcribed from the reference `TradeFrame.xml` (WoW/_extracted_framexml/TradeFrame.xml) —
sizes, offsets, anchors, art files, texcoords — adapted only in (a) naming (every `Foo` →
`BenillaFoo`) and (b) feed/intent wiring onto the trade script API
(`benilla-ui/src/script/trade.rs`) + `crate::ui_trade`'s feed/drain. The house dressing
convention (decisions 0084/0112/0120/0544).
Where the reference inherits a shared template benilla does NOT ship (ItemButtonTemplate,
UIPanelButtonTemplate, UIPanelCloseButton, MoneyInputFrameTemplate, SmallMoneyFrameTemplate),
the body is inlined with the reference's own geometry/art (the MailFrame.xml precedent). The
two money displays reuse `MerchantFrame.xml`'s global `BenillaMoney_*` coin trio (read-only in
P1); the reference's editable `MoneyInputFrame` on the player's side is decision 0592 P2.
SCOPE (decision 0592 P1): the window opens on an incoming/initiated trade and mirrors both
sides' items + gold live, the partner's portrait (the shared "npc" booth) + name, and the
accept glow; the Trade/Cancel buttons drive the accept/cancel verbs. DEFERRED to P2: setting
items/gold onto the window (the drag-drop slots + the editable money widget), so the item
slots carry NO OnClick/OnEnter here, and the slot tooltips (GameTooltip:SetTradePlayerItem)
are P2. The enchant-slot spell name (slot 7's "enchantment") is decision 0592 P3.
-->
<Script><![CDATA[
MAX_TRADE_ITEMS = 7 -- MAX_TRADE_ITEMS (ref TradeFrame.lua l.1)
-- One side's accept glow. A column's highlight and the enchant slot's below it are one
-- indicator in two frames — they light and drop together, never apart — so both the repaint
-- and TradeFrame_SetAcceptState go through here rather than each spelling out four calls.
-- The four frames are TradeHighlight{Player,Recipient}[Enchant], declared at the bottom.
local function setTradeGlow(column, enchantSlot, lit)
if lit then
column:Show()
enchantSlot:Show()
else
column:Hide()
enchantSlot:Hide()
end
end
-- ── Load / event dispatch (ref TradeFrame_OnLoad l.4 / TradeFrame_OnEvent l.18) ───────────
function TradeFrame_OnLoad()
local frame = this
frame:RegisterEvent("TRADE_SHOW")
frame:RegisterEvent("TRADE_UPDATE")
frame:RegisterEvent("TRADE_CLOSED")
frame:RegisterEvent("TRADE_PLAYER_ITEM_CHANGED")
frame:RegisterEvent("TRADE_TARGET_ITEM_CHANGED")
frame:RegisterEvent("TRADE_ACCEPT_UPDATE")
-- Our accepted gold, echoed back so the input reflects the server's clamp (ref
-- TradePlayerInputMoneyFrame's own PLAYER_TRADE_MONEY registration, l.565). Kept off the
-- general TRADE_UPDATE repaint so it never overwrites what the player is mid-typing.
frame:RegisterEvent("PLAYER_TRADE_MONEY")
end
function TradeFrame_OnEvent()
local frame = this
if event == "TRADE_SHOW" or event == "TRADE_UPDATE" then
ShowUIPanel(frame)
if not frame:IsVisible() then
CloseTrade()
return
end
TradeFrameTradeButton:Enable()
TradeFrame_Update()
elseif event == "PLAYER_TRADE_MONEY" then
BenillaMoneyInput_SetCopper("TradePlayerInputMoney", GetPlayerTradeMoney())
elseif event == "TRADE_CLOSED" then
HideUIPanel(frame)
elseif event == "TRADE_PLAYER_ITEM_CHANGED" then
TradeFrame_UpdatePlayerItem(arg1)
elseif event == "TRADE_TARGET_ITEM_CHANGED" then
TradeFrame_UpdateTargetItem(arg1)
elseif event == "TRADE_ACCEPT_UPDATE" then
TradeFrame_SetAcceptState(arg1, arg2)
end
end
function TradeFrame_OnShow()
TradeFrame.acceptState = 0
end
function TradeFrame_OnHide()
-- The client-side close verb (ref TradeFrame_OnHide l.144) → CMSG_CANCEL_TRADE + a local
-- clear (the app's drain); clear our money entry so a later trade opens fresh (ref l.146).
BenillaMoneyInput_ResetMoney("TradePlayerInputMoney")
CloseTrade()
end
-- A keystroke in our money input (ref TradeFrame_UpdateMoney l.162): read the copper total,
-- clamp to what we actually carry, and offer it → SetTradeMoney → CMSG_SET_TRADE_GOLD.
function TradeFrame_UpdateMoney()
local copper = BenillaMoneyInput_GetCopper("TradePlayerInputMoney")
if GetMoney and copper > GetMoney() then
copper = GetPlayerTradeMoney()
BenillaMoneyInput_SetCopper("TradePlayerInputMoney", copper)
end
SetTradeMoney(copper)
end
-- ── Full repaint (ref TradeFrame_Update l.39): portraits, names, all 7 slots each side, the
-- two money displays, the accept highlights hidden. ────────────────────────────────────
function TradeFrame_Update()
SetPortraitTexture(TradeFramePlayerPortrait, "player")
-- The partner rides the shared "npc" portrait booth (the app points it at the partner
-- entity, decision 0592); the name comes from GetTradePartnerName() in place of the
-- reference's UnitName("NPC").
SetPortraitTexture(TradeFrameRecipientPortrait, "npc")
TradeFramePlayerNameText:SetText(UnitName("player") or "")
TradeFrameRecipientNameText:SetText(GetTradePartnerName() or "")
for i = 1, MAX_TRADE_ITEMS do
TradeFrame_UpdatePlayerItem(i)
TradeFrame_UpdateTargetItem(i)
end
-- Our gold is the editable input, reflected via PLAYER_TRADE_MONEY (not repainted here, so
-- the repaint never clobbers mid-typing); only the partner's read-only gold repaints.
BenillaMoney_Set("TradeRecipientMoneyFrame", GetTargetTradeMoney())
-- A repaint puts both glows down. TRADE_ACCEPT_UPDATE is the only thing that raises
-- them, so whatever is standing comes straight back on the next one.
setTradeGlow(TradeHighlightPlayer, TradeHighlightPlayerEnchant, false)
setTradeGlow(TradeHighlightRecipient, TradeHighlightRecipientEnchant, false)
end
-- One slot's paint, shared by both columns (ref TradeFrame_UpdatePlayerItem l.54 /
-- UpdateTargetItem l.83). `isTarget` gates the recipient-only not-usable red tint.
function BenillaTradeSlot_SetItem(prefix, id, name, texture, numItems, isUsable, enchantment, isTarget)
local nameText = getglobal(prefix.."Name")
local icon = getglobal(prefix.."ItemButtonIcon")
local count = getglobal(prefix.."ItemButtonCount")
-- The enchant slot (id 7): the enchant name (P3) or the "will not be traded" note.
if id == MAX_TRADE_ITEMS then
if name then
if enchantment then
nameText:SetText("|cff20ff20"..enchantment.."|r")
else
nameText:SetText("|cffffffffWill Not Be Traded|r")
end
else
nameText:SetText("")
end
else
nameText:SetText(name or "")
end
if texture then
icon:SetTexture(texture)
icon:Show()
else
icon:Hide()
end
if numItems and numItems > 1 then
count:SetText(numItems)
count:Show()
else
count:SetText("")
count:Hide()
end
-- The recipient not-usable red tint (ref l.105-113): red when the partner offers an item
-- our class/level/skill can't use; white otherwise (and always white on our own column).
local slotTex = getglobal(prefix.."SlotTexture")
local nameFrame = getglobal(prefix.."NameFrame")
if isTarget and name and not isUsable then
icon:SetVertexColor(0.9, 0, 0)
nameFrame:SetVertexColor(0.9, 0, 0)
slotTex:SetVertexColor(1.0, 0, 0)
else
icon:SetVertexColor(1, 1, 1)
nameFrame:SetVertexColor(1, 1, 1)
slotTex:SetVertexColor(1, 1, 1)
end
end
function TradeFrame_UpdatePlayerItem(id)
local name, texture, numItems, isUsable, enchantment = GetTradePlayerItemInfo(id)
BenillaTradeSlot_SetItem("TradePlayerItem"..id, id, name, texture, numItems, isUsable, enchantment, nil)
end
function TradeFrame_UpdateTargetItem(id)
local name, texture, numItems, quality, isUsable, enchantment = GetTradeTargetItemInfo(id)
BenillaTradeSlot_SetItem("TradeRecipientItem"..id, id, name, texture, numItems, isUsable, enchantment, 1)
end
-- A trade slot's item button as a cursor drop target (decision 0592 P2, ref ClickTradeButton /
-- ClickTargetTradeButton, TradeFrame.xml l.135/101). Both columns share the slot template, so
-- player-vs-recipient is read off the slot frame's name; the slot number is its frame id. Our
-- slot drops/clears a bag item (SET/CLEAR_TRADE_ITEM); the partner's column is read-only.
function BenillaTradeSlot_OnClick(button)
local slot = button:GetParent()
local id = slot:GetID()
if string.find(slot:GetName(), "Player", 1, true) then
ClickTradeButton(id)
else
ClickTargetTradeButton(id)
end
end
-- Who has pressed Trade (spec: ref TradeFrame_SetAcceptState l.116). The event carries BOTH
-- sides' states, so this is a straight repaint of two indicators rather than a toggle — and
-- our own half is kept on the frame, because the Cancel button below is the only reader of
-- whether there is an accept of ours to withdraw.
--
-- The extra thing our own accept does is lock the Trade button, so a standing accept cannot
-- be sent a second time; Cancel is what takes it back.
function TradeFrame_SetAcceptState(playerState, targetState)
TradeFrame.acceptState = playerState
local weAccepted = playerState == 1
setTradeGlow(TradeHighlightPlayer, TradeHighlightPlayerEnchant, weAccepted)
setTradeGlow(TradeHighlightRecipient, TradeHighlightRecipientEnchant, targetState == 1)
if weAccepted then
TradeFrameTradeButton:Disable()
else
TradeFrameTradeButton:Enable()
end
end
-- Cancel is two verbs on one button (spec: ref TradeFrameCancelButton_OnClick l.136). With
-- an accept of ours standing it withdraws only THAT and the trade stays open — the button
-- is the only way back out of the lock above. With no accept up there is nothing to
-- withdraw, so it means "leave", and closing the window is what sends that (→ OnHide →
-- CloseTrade).
function TradeFrameCancelButton_OnClick()
if TradeFrame.acceptState ~= 1 then
HideUIPanel(TradeFrame)
return
end
CancelTradeAccept()
end
]]></Script>
<!-- The accept-glow highlight (ref TradeHighlightTemplate l.4): a 3-slice UI-TradeFrame-Highlight
(top/bottom caps + a stretched middle), sized per-instance. -->
<Frame name="BenillaTradeHighlightTemplate" hidden="false" virtual="true">
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTop" file="Interface\TradeFrame\UI-TradeFrame-Highlight">
<Size><AbsDimension x="161" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0" right="0.62890625" top="0" bottom="0.0625"/>
</Texture>
<Texture name="$parentBottom" file="Interface\TradeFrame\UI-TradeFrame-Highlight">
<Size><AbsDimension x="161" y="16"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
<TexCoords left="0" right="0.62890625" top="0.9375" bottom="1.0"/>
</Texture>
<Texture name="$parentMiddle" file="Interface\TradeFrame\UI-TradeFrame-Highlight">
<Size><AbsDimension x="161" y="10"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="$parentTop" relativePoint="BOTTOM"/>
<Anchor point="BOTTOM" relativeTo="$parentBottom" relativePoint="TOP"/>
</Anchors>
<TexCoords left="0" right="0.62890625" top="0.0625" bottom="0.9375"/>
</Texture>
</Layer>
</Layers>
</Frame>
<!-- One trade slot (ref TradeItemTemplate l.38 + PlayerTradeItemTemplate's inlined ItemButton
l.120): the empty-slot art, the name plate, the item name, and a 37px item button (icon +
stack count). Display-only in P1 — no OnClick/OnEnter (decision 0592 P2). -->
<Frame name="BenillaTradeItemTemplate" virtual="true">
<Size><AbsDimension x="153" y="37"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentSlotTexture" 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>
<Texture name="$parentNameFrame" file="Interface\QuestFrame\UI-QuestItemNameFrame">
<Size><AbsDimension x="124" y="64"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentSlotTexture" relativePoint="RIGHT"><Offset><AbsDimension x="-20" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
<FontString name="$parentName" inherits="GameFontNormalSmall" text="" justifyH="LEFT">
<Size><AbsDimension x="90" y="30"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentNameFrame"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentItemButton">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parent"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon" hidden="true" setAllPoints="true"/>
</Layer>
<Layer level="ARTWORK">
<FontString name="$parentCount" inherits="NumberFontNormal" hidden="true" justifyH="RIGHT">
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-2" y="2"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<!-- A cursor drop target (decision 0592 P2): a click or a drag-release routes through
the shared slot handler, which places/clears on our side and is inert on the
partner's. Both gestures share one path, the reference's drag/click contract. -->
<Scripts>
<OnClick>BenillaTradeSlot_OnClick(self)</OnClick>
<OnReceiveDrag>BenillaTradeSlot_OnClick(self)</OnReceiveDrag>
</Scripts>
</Button>
</Frames>
</Frame>
<!-- The enchant / non-traded slot art (ref TradeRecipientItem7/TradePlayerItem7 l.393): the
EnchantIcon overlay sits over slot 7's item button. A virtual twin of the base slot with the
extra icon, instantiated only for id 7. -->
<Frame name="BenillaTradeEnchantItemTemplate" inherits="BenillaTradeItemTemplate" virtual="true">
<Frames>
<Frame name="$parentEnchant" setAllPoints="false">
<Size><AbsDimension x="62" y="62"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture file="Interface\TradeFrame\UI-TradeFrame-EnchantIcon" setAllPoints="true"/>
</Layer>
</Layers>
</Frame>
</Frames>
</Frame>
<Frame name="TradeFrame" parent="UIParent" toplevel="true" movable="true" enableMouse="true" hidden="true">
<!-- ref TradeFrame l.164: 384×512; ShowUIPanel slots it left. -->
<Size><AbsDimension x="384" y="512"/></Size>
<HitRectInsets><AbsInset left="0" right="35" top="0" bottom="72"/></HitRectInsets>
<Layers>
<Layer level="BACKGROUND">
<!-- ref l.180: the two 60×60 unit portraits (recipient right, player left). -->
<Texture name="TradeFrameRecipientPortrait">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="183" y="-6"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="TradeFramePlayerPortrait">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- ref l.205: the four corner slabs. -->
<Texture file="Interface\TradeFrame\UI-TradeFrame-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\TradeFrame\UI-TradeFrame-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
</Texture>
<Texture file="Interface\TradeFrame\UI-TradeFrame-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture file="Interface\TradeFrame\UI-TradeFrame-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
<FontString name="TradeFrameRecipientNameText" inherits="GameFontNormal" text="">
<Size><AbsDimension x="80" y="12"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeFrame"><Offset><AbsDimension x="245" y="-17"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="TradeFramePlayerNameText" inherits="GameFontNormal" text="">
<Size><AbsDimension x="100" y="12"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeFrame"><Offset><AbsDimension x="75" y="-17"/></Offset></Anchor></Anchors>
</FontString>
<!-- ref l.262: the "Will Be Enchanted" column labels above the enchant slots. -->
<FontString name="TradeFramePlayerEnchantText" inherits="GameFontHighlightSmall" text="Will Be Enchanted">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeFrame"><Offset><AbsDimension x="26" y="-374"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="TradeFrameRecipientEnchantText" inherits="GameFontHighlightSmall" text="Will Be Enchanted">
<Anchors><Anchor point="LEFT" relativeTo="TradeFramePlayerEnchantText"><Offset><AbsDimension x="170" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The accept-glow highlights, one per column + its enchant slot (ref l.283). -->
<Frame name="TradeHighlightPlayer" inherits="BenillaTradeHighlightTemplate" hidden="true">
<Size><AbsDimension x="161" y="266"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeFrame"><Offset><AbsDimension x="19" y="-100"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradeHighlightRecipient" inherits="BenillaTradeHighlightTemplate" hidden="true">
<Size><AbsDimension x="161" y="266"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="189" y="-100"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradeHighlightPlayerEnchant" inherits="BenillaTradeHighlightTemplate" hidden="true">
<Size><AbsDimension x="161" y="61"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeHighlightPlayer" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradeHighlightRecipientEnchant" inherits="BenillaTradeHighlightTemplate" hidden="true">
<Size><AbsDimension x="161" y="61"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeHighlightRecipient" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor></Anchors>
</Frame>
<!-- The partner's 6 trade slots + the 7th enchant slot (ref TradeRecipientItem1..7 l.331). -->
<Frame name="TradeRecipientItem1" inherits="BenillaTradeItemTemplate" id="1">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="195" y="-104"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradeRecipientItem2" inherits="BenillaTradeItemTemplate" id="2">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeRecipientItem1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradeRecipientItem3" inherits="BenillaTradeItemTemplate" id="3">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeRecipientItem2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradeRecipientItem4" inherits="BenillaTradeItemTemplate" id="4">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeRecipientItem3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradeRecipientItem5" inherits="BenillaTradeItemTemplate" id="5">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeRecipientItem4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradeRecipientItem6" inherits="BenillaTradeItemTemplate" id="6">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeRecipientItem5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradeRecipientItem7" inherits="BenillaTradeEnchantItemTemplate" id="7">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeRecipientItem6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-28"/></Offset></Anchor></Anchors>
</Frame>
<!-- Our 6 trade slots + the 7th enchant slot (ref TradePlayerItem1..7 l.410). -->
<Frame name="TradePlayerItem1" inherits="BenillaTradeItemTemplate" id="1">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="26" y="-104"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradePlayerItem2" inherits="BenillaTradeItemTemplate" id="2">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradePlayerItem1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradePlayerItem3" inherits="BenillaTradeItemTemplate" id="3">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradePlayerItem2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradePlayerItem4" inherits="BenillaTradeItemTemplate" id="4">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradePlayerItem3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradePlayerItem5" inherits="BenillaTradeItemTemplate" id="5">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradePlayerItem4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradePlayerItem6" inherits="BenillaTradeItemTemplate" id="6">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradePlayerItem5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="TradePlayerItem7" inherits="BenillaTradeEnchantItemTemplate" id="7">
<Anchors><Anchor point="TOPLEFT" relativeTo="TradePlayerItem6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-28"/></Offset></Anchor></Anchors>
</Frame>
<!-- The Trade button (ref l.502, UIPanelButtonTemplate inlined): OnClick → AcceptTrade. -->
<Button name="TradeFrameTradeButton" text="Trade">
<Size><AbsDimension x="85" y="22"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT" relativeTo="TradeFrame" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-113" y="55"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<ButtonText name="$parentText"/>
<NormalFont inherits="GameFontNormal"/><HighlightFont inherits="GameFontHighlight"/><DisabledFont inherits="GameFontDisable"/>
<Scripts><OnClick>AcceptTrade()</OnClick></Scripts>
</Button>
<Button name="TradeFrameCancelButton" text="Cancel">
<Size><AbsDimension x="77" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="TradeFrameTradeButton" relativePoint="TOPRIGHT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<ButtonText name="$parentText"/>
<NormalFont inherits="GameFontNormal"/><HighlightFont inherits="GameFontHighlight"/><DisabledFont inherits="GameFontDisable"/>
<Scripts><OnClick>TradeFrameCancelButton_OnClick()</OnClick></Scripts>
</Button>
<!-- The window close X (ref l.544, UIPanelCloseButton inlined). -->
<Button name="TradeFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativeTo="TradeFrame" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-25" 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>HideUIPanel(TradeFrame)</OnClick></Scripts>
</Button>
<!-- Our gold — EDITABLE (decision 0592 P2, ref TradePlayerInputMoneyFrame l.553): the shared
BenillaMoneyInput trio (three numeric edit boxes Gold/Silver/Copper). A keystroke drives
TradeFrame_UpdateMoney → SetTradeMoney → CMSG_SET_TRADE_GOLD; the server's echo is
reflected back through PLAYER_TRADE_MONEY (below). Box layout copied from the proven
send-mail widget (MailFrame.xml). -->
<Frame name="TradePlayerInputMoney">
<Size><AbsDimension x="176" y="18"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="26" y="-73"/></Offset></Anchor></Anchors>
<Frames>
<EditBox name="$parentGold" historyLines="0" numeric="true" autoFocus="false">
<Size><AbsDimension x="58" y="20"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentLeft" file="Interface\Common\Common-Input-Border"><Size><AbsDimension x="8" y="20"/></Size><Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors><TexCoords left="0" right="0.0625" top="0" bottom="0.625"/></Texture>
<Texture name="$parentRight" file="Interface\Common\Common-Input-Border"><Size><AbsDimension x="8" y="20"/></Size><Anchors><Anchor point="RIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors><TexCoords left="0.9375" right="1.0" top="0" bottom="0.625"/></Texture>
<Texture name="$parentMiddle" file="Interface\Common\Common-Input-Border"><Size><AbsDimension x="10" y="20"/></Size><Anchors><Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"/><Anchor point="RIGHT" relativeTo="$parentRight" relativePoint="LEFT"/></Anchors><TexCoords left="0.0625" right="0.9375" top="0" bottom="0.625"/></Texture>
<Texture file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="LEFT" relativePoint="RIGHT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor></Anchors><TexCoords left="0" right="0.25" top="0" bottom="1"/></Texture>
</Layer>
</Layers>
<Scripts>
<OnTabPressed>getglobal(this:GetParent():GetName().."Silver"):SetFocus()</OnTabPressed>
<OnEnterPressed>getglobal(this:GetParent():GetName().."Silver"):SetFocus()</OnEnterPressed>
<OnEscapePressed>this:ClearFocus()</OnEscapePressed>
<OnTextChanged>if ( BenillaMoneyInput_Consume("TradePlayerInputMoney") ) then TradeFrame_UpdateMoney() end</OnTextChanged>
<OnEditFocusLost>this:HighlightText(0, 0)</OnEditFocusLost>
<OnEditFocusGained>this:HighlightText()</OnEditFocusGained>
</Scripts>
<FontString inherits="ChatFontNormal"/>
</EditBox>
<EditBox name="$parentSilver" letters="2" historyLines="0" numeric="true" autoFocus="false">
<Size><AbsDimension x="30" y="20"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentGold" relativePoint="RIGHT"><Offset><AbsDimension x="26" y="0"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentLeft" file="Interface\Common\Common-Input-Border"><Size><AbsDimension x="8" y="20"/></Size><Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors><TexCoords left="0" right="0.0625" top="0" bottom="0.625"/></Texture>
<Texture name="$parentRight" file="Interface\Common\Common-Input-Border"><Size><AbsDimension x="8" y="20"/></Size><Anchors><Anchor point="RIGHT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor></Anchors><TexCoords left="0.9375" right="1.0" top="0" bottom="0.625"/></Texture>
<Texture name="$parentMiddle" file="Interface\Common\Common-Input-Border"><Size><AbsDimension x="10" y="20"/></Size><Anchors><Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"/><Anchor point="RIGHT" relativeTo="$parentRight" relativePoint="LEFT"/></Anchors><TexCoords left="0.0625" right="0.9375" top="0" bottom="0.625"/></Texture>
<Texture file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="LEFT" relativePoint="RIGHT"><Offset><AbsDimension x="-8" y="0"/></Offset></Anchor></Anchors><TexCoords left="0.25" right="0.5" top="0" bottom="1"/></Texture>
</Layer>
</Layers>
<Scripts>
<OnTabPressed>getglobal(this:GetParent():GetName().."Copper"):SetFocus()</OnTabPressed>
<OnEnterPressed>getglobal(this:GetParent():GetName().."Copper"):SetFocus()</OnEnterPressed>
<OnEscapePressed>this:ClearFocus()</OnEscapePressed>
<OnTextChanged>if ( BenillaMoneyInput_Consume("TradePlayerInputMoney") ) then TradeFrame_UpdateMoney() end</OnTextChanged>
<OnEditFocusLost>this:HighlightText(0, 0)</OnEditFocusLost>
<OnEditFocusGained>this:HighlightText()</OnEditFocusGained>
</Scripts>
<FontString inherits="ChatFontNormal"/>
</EditBox>
<EditBox name="$parentCopper" letters="2" historyLines="0" numeric="true" autoFocus="false">
<Size><AbsDimension x="30" y="20"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentSilver" relativePoint="RIGHT"><Offset><AbsDimension x="16" y="0"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentLeft" file="Interface\Common\Common-Input-Border"><Size><AbsDimension x="8" y="20"/></Size><Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors><TexCoords left="0" right="0.0625" top="0" bottom="0.625"/></Texture>
<Texture name="$parentRight" file="Interface\Common\Common-Input-Border"><Size><AbsDimension x="8" y="20"/></Size><Anchors><Anchor point="RIGHT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor></Anchors><TexCoords left="0.9375" right="1.0" top="0" bottom="0.625"/></Texture>
<Texture name="$parentMiddle" file="Interface\Common\Common-Input-Border"><Size><AbsDimension x="10" y="20"/></Size><Anchors><Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"/><Anchor point="RIGHT" relativeTo="$parentRight" relativePoint="LEFT"/></Anchors><TexCoords left="0.0625" right="0.9375" top="0" bottom="0.625"/></Texture>
<Texture file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="LEFT" relativePoint="RIGHT"><Offset><AbsDimension x="-8" y="0"/></Offset></Anchor></Anchors><TexCoords left="0.5" right="0.75" top="0" bottom="1"/></Texture>
</Layer>
</Layers>
<Scripts>
<OnEscapePressed>this:ClearFocus()</OnEscapePressed>
<OnTextChanged>if ( BenillaMoneyInput_Consume("TradePlayerInputMoney") ) then TradeFrame_UpdateMoney() end</OnTextChanged>
<OnEditFocusLost>this:HighlightText(0, 0)</OnEditFocusLost>
<OnEditFocusGained>this:HighlightText()</OnEditFocusGained>
</Scripts>
<FontString inherits="ChatFontNormal"/>
</EditBox>
</Frames>
</Frame>
<!-- The partner's gold (read-only — GetTargetTradeMoney; ref TradeRecipientMoneyFrame l.574). -->
<Frame name="TradeRecipientMoneyFrame">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-40" y="-80"/></Offset></Anchor></Anchors>
<Frames>
<Frame name="$parentCoin1">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.5" right="0.75" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
</Frame>
<Frame name="$parentCoin2">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativeTo="$parentCoin1" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.25" right="0.5" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
</Frame>
<Frame name="$parentCoin3">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativeTo="$parentCoin2" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0" right="0.25" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
</Frame>
</Frames>
</Frame>
</Frames>
<Scripts>
<OnLoad>TradeFrame_OnLoad()</OnLoad>
<OnEvent>TradeFrame_OnEvent()</OnEvent>
<OnShow>TradeFrame_OnShow(); PlaySound("igCharacterInfoOpen")</OnShow>
<OnHide>TradeFrame_OnHide(); PlaySound("igCharacterInfoClose")</OnHide>
</Scripts>
</Frame>
</Ui>
File diff suppressed because it is too large Load Diff
@@ -1,911 +0,0 @@
<Ui>
<!--
benilla's class/profession trainer window (decisions 0237/0247/0251), a faithful port of the
TBC-backported `Blizzard_TrainerUI` addon the director's client runs (0246): a client-sorted,
collapsible **skill-line tree** with a **dropdown** state filter and a real **scroll bar**. The
window art/geometry are the real 1.12 `ClassTrainerFrame` (MerchantFrame.xml's four-quadrant-slab
idiom); the list/sort/collapse/filter logic is transcribed from `Blizzard_TrainerUI.lua` onto
benilla's engine (`crates/benilla-ui/src/script/trainer.rs`, which builds the tree — decision
0247) + the shared faux-scroll kit (ScrollTemplates.xml, decision 0251). This REPLACES the
earlier flat-list placeholder (git has it): flat state-as-category rows + inline sort checkboxes
+ a chunk-local scroll offset, which stood in until the tree/scrollbar/dropdown landed.
Modeled on the two known-good dressed windows for the framing:
- MerchantFrame.xml — the 384×512 four-quadrant window art (no <Backdrop>: the art IS the
frame), the NPC-session lifecycle (RegisterEvent trio + PLAYER_MONEY, SHOW/UPDATE/CLOSED
dispatch, OnShow/OnHide PlaySound with CloseTrainer() on hide), the "npc" model-portrait
binding, and the BenillaMoney_* coin rig (detail cost + player purse). The close X reuses
the minimize-button art (this engine ships no UIPanelCloseButton).
- QuestLogFrame.xml — the list-row Button template shape ($parent-named child regions wired in
<OnLoad>) and the persistent selection-highlight Frame (a child Frame whose ARTWORK Texture
renders when Show()n, moved onto the selected visible row).
WHAT'S NEW vs. the placeholder (decision 0247's tree, 0251's scroll bar):
- THE LIST IS A TREE. GetTrainerServiceInfo(i) now returns interleaved "header" rows (a skill
line's name, +/- fold) and service rows (name/state), 1-based into the VISIBLE tree. Update
renders each row by type; a header click folds its group, a service click selects it.
- THE FILTER IS A DROPDOWN. BenillaTrainerFilterDropDown (over UIDropDownMenu.xml's kit) with
three keepShownOnClick check rows (Available/Unavailable/Already Known) driving the engine's
state-visibility filter — replacing the three inline sort checkboxes. Its three states are
SAVED VARIABLES (decision 1128): defaults 1/1/0 at file scope — "Already Known" starts OFF,
the shipped 1.12.1 Blizzard_TrainerUI.lua:6-9's own values — restored from
benilla/saved-variables.lua at load, and pushed into the engine on show because the list
builder resets its mask on every packet.
- THE SCROLL BAR IS REAL. BenillaTrainerListScrollFrame (FauxScrollFrameTemplate): a
draggable Slider bar + up/down step arrows over the fixed 11 rows (the faux-scroll re-binds
rows to a moving offset — it never really scrolls a child). The wheel still lives on the
WINDOW (the engine bubbles a spin up the parent chain, so a spin over a row reaches here).
- COLLAPSE WORKS. The "All" tab collapses/expands every group (Collapse/ExpandTrainerSkillLine(0));
its +/- icon reflects whether all groups are folded (the backport's numHeaders/notExpanded).
FLAT layout — a CHOICE, and it was written down here as a constraint that does not exist.
This block used to claim "a child <Frame> shown at runtime does not render its own <Layers>
FontStrings in this engine". That is FALSE, and it was propagated from here into two more
windows before anyone probed it (decision 1517): MailFrame's own SendMailTitleText lives in a
runtime-shown pane's Layers and has rendered correctly the whole time. The detail-pane
FontStrings + the title/greeting do live on BenillaTrainerFrame's OWN <Layers>, and that is
fine — it is simply one way to lay this out, not the only one available. Per-row regions live
on the row Button template. The detail is a FIXED (non-scrolling) pane
(our descriptions are empty for now) — no real ScrollFrame, unlike the ref's DetailScrollFrame.
Adaptations / deviations (flagged):
- TITLE: the trainer's NAME rides arg1 of TRAINER_SHOW/UPDATE (the app resolves the creature
name ask-once via the NameCache — the MerchantFrame vendor-name precedent); "Trainer" until
it lands.
- PORTRAIT: the "npc" unit-token model bake (MerchantFrame's binding — the interaction NPC's face).
- GlobalStrings the ref reads (REQUIRES_LABEL, TRAINER_REQ_*, COSTS_LABEL, the labels) are
defined LOCALLY below as the byte-exact 1.12 enUS values (interface.MPQ GlobalStrings.lua,
line-cited); NORMAL/GRAY/HIGHLIGHT_FONT_COLOR are Fonts.xml globals.
- The affordability RED-TINT of the cost coins (ref SetMoneyFrameColor) is BenillaMoney_SetColor.
- The profession-confirm StaticPopup is skipped for v1 (Train buys directly); a later refinement.
Registers at the left UIPanel slot like Gossip/Merchant/Quest/QuestLog (UiPanels.xml) — opening
it competes for/replaces those. It IS an NPC session: CloseTrainer() fires on hide, and the
standardized range guard (crate::ui_session) client-side-closes it when the player walks away.
-->
<Script><![CDATA[
-- ── GlobalStrings / labels the ref reads (byte-exact 1.12 enUS, interface.MPQ) ────────────────
-- Chunk-local upvalues: the global functions below close over them; inline XML handlers (separate
-- chunks) can't, so every handler routes to a function here. `_RED` variants redden only the
-- number/name (met = white, unmet = red), exactly as the real requirements line reads.
local PARENS_TEMPLATE = "(%s)" -- GlobalStrings.lua:2468
local REQUIRES_LABEL = "Requires:" -- REQUIRES_LABEL (2768)
local COSTS_LABEL = "Cost:" -- COSTS_LABEL (667)
local UNKNOWN_LABEL = "Unknown" -- UNKNOWN (3860)
local FILTER_LABEL = "Filter" -- FILTER (1297)
local TRAINER_REQ_LEVEL = "Level |cffffffff%d|r" -- TRAINER_REQ_LEVEL (3739)
local TRAINER_REQ_LEVEL_RED = "Level |cffff2020%d|r" -- TRAINER_REQ_LEVEL_RED (3740)
local TRAINER_REQ_SKILL_RANK = "%s (|cffffffff%d|r)" -- TRAINER_REQ_SKILL_RANK (3741)
local TRAINER_REQ_SKILL_RANK_RED = "%s (|cffff2020%d|r)" -- TRAINER_REQ_SKILL_RANK_RED (3742)
local TRAINER_REQ_ABILITY = "|cffffffff%s|r" -- TRAINER_REQ_ABILITY (3737)
local TRAINER_REQ_ABILITY_RED = "|cffff2020%s|r" -- TRAINER_REQ_ABILITY_RED (3738)
-- ── The state filter's three SAVED VARIABLES (decision 1128) ─────────────────────────────────
-- The reference's own globals, values and mechanism: Blizzard_TrainerUI.lua:6-9 assigns these
-- at FILE SCOPE and its `.toc` declares them `## SavedVariables`; the client executes the
-- saved file over them AFTER this file loads and BEFORE the load event, so a remembered value
-- wins over the default. benilla's ported UI is one FrameXML tree rather than addons, so the
-- declaration rides `RegisterForSave` (FrameXML's own API for its 38 globals) and the same
-- ordering is the app's load seam. File scope is load-bearing: assign these in OnLoad and the
-- default would clobber the restored value every launch.
TRAINER_FILTER_AVAILABLE = 1
TRAINER_FILTER_UNAVAILABLE = 1
TRAINER_FILTER_USED = 0
RegisterForSave("TRAINER_FILTER_AVAILABLE")
RegisterForSave("TRAINER_FILTER_UNAVAILABLE")
RegisterForSave("TRAINER_FILTER_USED")
local LABEL_AVAILABLE = "Available" -- AVAILABLE (84)
local LABEL_UNAVAILABLE = "Unavailable" -- UNAVAILABLE (3840)
local LABEL_USED = "Already Known" -- USED (3868)
-- The font colour codes the dropdown rows wrap their labels in (GlobalStrings.lua: the *_CODE
-- family) — green/red/gray, closed with |r.
local GREEN_CODE = "|cff20ff20"
local RED_CODE = "|cffff2020"
local GRAY_CODE = "|cff808080"
local CODE_CLOSE = "|r"
-- CLASS_TRAINER_SKILLS_DISPLAYED / CLASS_TRAINER_SKILL_HEIGHT (ref Blizzard_TrainerUI.lua:2-3).
local BENILLA_TRAINER_ROWS = 11
local BENILLA_TRAINER_ROW_HEIGHT = 16
-- ── Detail pane ─────────────────────────────────────────────────────────────────────────────
-- Nothing selected → blank every detail region: a FontString "hides" via SetText(""), while the
-- icon Button + coin frames Hide. Predates region Show/Hide (region.rs has the real pair now);
-- equivalent either way, so the blank-text idiom stays.
function BenillaTrainer_HideDetails()
getglobal("BenillaTrainerSkillName"):SetText("")
getglobal("BenillaTrainerSkillRequirements"):SetText("")
getglobal("BenillaTrainerSkillDescription"):SetText("")
getglobal("BenillaTrainerCostLabel"):SetText("")
getglobal("BenillaTrainerSkillIcon"):Hide()
BenillaMoney_Clear("BenillaTrainerDetailMoney")
getglobal("BenillaTrainerTrainButton"):Disable()
end
-- Fill the detail pane for the selected service `id` (1-based visible index) — the transcribed
-- ClassTrainer_SetSelection detail half: name/sub name, the Requires: string (built term-for-term
-- as the ref does), the icon, the cost coins + label, the description, and Train's enable state.
function BenillaTrainer_ShowDetails(id)
local name = GetTrainerServiceInfo(id)
getglobal("BenillaTrainerSkillName"):SetText(name or UNKNOWN_LABEL)
-- The detail header shows the spell NAME only — no "(Rank N)" beside it. A deliberate
-- divergence from the ref (which sets a ClassTrainerSubSkillName subtext here): the rank is
-- already on the spell's list row, so a second copy floated to the right of the name just
-- read as clutter (director's call).
local icon = getglobal("BenillaTrainerSkillIcon")
icon:SetNormalTexture(GetTrainerServiceIcon(id) or BENILLA_FALLBACK_ICON)
icon:Show()
-- Requirements string (ref term-for-term): level gate, skill gate, each prerequisite ability;
-- each red when unmet (hasReq nil). Our feed has no pet-learn / step requirements.
local requirements = ""
local separator = ""
local reqLevel = GetTrainerServiceLevelReq(id)
if reqLevel and reqLevel > 1 then
separator = ", "
if (UnitLevel("player") or 0) >= reqLevel then
requirements = requirements .. format(TRAINER_REQ_LEVEL, reqLevel)
else
requirements = requirements .. format(TRAINER_REQ_LEVEL_RED, reqLevel)
end
end
local skill, rank, hasReq = GetTrainerServiceSkillReq(id)
if skill then
if hasReq then
requirements = requirements .. separator .. format(TRAINER_REQ_SKILL_RANK, skill, rank)
else
requirements = requirements .. separator .. format(TRAINER_REQ_SKILL_RANK_RED, skill, rank)
end
separator = ", "
end
local numReq = GetTrainerServiceNumAbilityReq(id)
for i = 1, numReq do
local ability, abilityHas = GetTrainerServiceAbilityReq(id, i)
if ability then
if abilityHas then
requirements = requirements .. separator .. format(TRAINER_REQ_ABILITY, ability)
else
requirements = requirements .. separator .. format(TRAINER_REQ_ABILITY_RED, ability)
end
separator = ", "
end
end
if requirements ~= "" then
getglobal("BenillaTrainerSkillRequirements"):SetText(REQUIRES_LABEL .. " " .. requirements)
else
getglobal("BenillaTrainerSkillRequirements"):SetText("")
end
-- Cost (ref): a free service shows no coins/label; otherwise the "Cost:" label + coins,
-- reddened when unaffordable (BenillaMoney_SetColor: white when GetMoney() >= cost, else red).
local moneyCost = GetTrainerServiceCost(id)
if moneyCost and moneyCost > 0 then
getglobal("BenillaTrainerCostLabel"):SetText(COSTS_LABEL)
BenillaMoney_Set("BenillaTrainerDetailMoney", moneyCost, true)
if (GetMoney() or 0) >= moneyCost then
BenillaMoney_SetColor("BenillaTrainerDetailMoney", 1.0, 1.0, 1.0)
else
BenillaMoney_SetColor("BenillaTrainerDetailMoney", 1.0, 0.1, 0.1)
end
else
getglobal("BenillaTrainerCostLabel"):SetText("")
BenillaMoney_Clear("BenillaTrainerDetailMoney")
end
getglobal("BenillaTrainerSkillDescription"):SetText(GetTrainerServiceDescription(id))
-- Train enable (ref): only an available AND affordable service. The talent/profession-point
-- gate is dropped (talentPts always 0; the profession-confirm is a later refinement).
local _, _, serviceType = GetTrainerServiceInfo(id)
local affordable = (GetMoney() or 0) >= (moneyCost or 0)
if serviceType == "available" and affordable then
getglobal("BenillaTrainerTrainButton"):Enable()
else
getglobal("BenillaTrainerTrainButton"):Disable()
end
end
-- ── The list page ────────────────────────────────────────────────────────────────────────────
-- Pick the first learnable (available) service, else the first non-header service (ref
-- ClassTrainer_SelectFirstLearnableSkill — the backport hardcodes index 2, the first service
-- under the first header; we find it, robust to which group leads / a leading collapsed group).
local function selectFirstService()
local n = GetNumTrainerServices()
for i = 1, n do
local _, _, serviceType = GetTrainerServiceInfo(i)
if serviceType == "available" then
SelectTrainerService(i)
return
end
end
for i = 1, n do
local _, _, serviceType = GetTrainerServiceInfo(i)
if serviceType ~= "header" then
SelectTrainerService(i)
return
end
end
end
-- Set the collapse-all tab's +/- icon from whether every group is folded (ref
-- ClassTrainerFrame_Update's numHeaders/notExpanded tail).
local function updateCollapseAllIcon(numServices)
local numHeaders, notExpanded = 0, 0
for i = 1, numServices do
local name, _, serviceType, isExpanded = GetTrainerServiceInfo(i)
if name and serviceType == "header" then
numHeaders = numHeaders + 1
if not isExpanded then notExpanded = notExpanded + 1 end
end
end
local b = getglobal("BenillaTrainerCollapseAllButton")
if numHeaders > 0 and notExpanded == numHeaders then
b.collapsed = 1
b:SetNormalTexture("Interface\\Buttons\\UI-PlusButton-Up")
else
b.collapsed = nil
b:SetNormalTexture("Interface\\Buttons\\UI-MinusButton-Up")
end
end
function BenillaTrainerFrame_Update()
getglobal("BenillaTrainerGreetingText"):SetText(GetTrainerGreetingText())
BenillaMoney_Set("BenillaTrainerMoney", GetMoney())
local numServices = GetNumTrainerServices()
if numServices == 0 then
getglobal("BenillaTrainerCollapseAllButton"):Disable()
else
getglobal("BenillaTrainerCollapseAllButton"):Enable()
end
-- The faux-scroll bar reconciles to the row count, then hands back the top-row offset.
local scrollFrame = getglobal("BenillaTrainerListScrollFrame")
FauxScrollFrame_Update(scrollFrame, numServices, BENILLA_TRAINER_ROWS, BENILLA_TRAINER_ROW_HEIGHT)
local skillOffset = FauxScrollFrame_GetOffset(scrollFrame)
getglobal("BenillaTrainerSkillHighlight"):Hide()
-- Fill the visible rows (ref ClassTrainerFrame_Update's row loop). Header rows show the skill
-- line name + a +/- fold; service rows blank the fold, indent the name two spaces, and colour
-- name+subtext by state (available green / used gray / unavailable red).
for i = 1, BENILLA_TRAINER_ROWS do
local button = getglobal("BenillaTrainerService" .. i)
local index = i + skillOffset
if index <= numServices then
local name, subText, serviceType, isExpanded = GetTrainerServiceInfo(index)
if not name then name = UNKNOWN_LABEL end
-- Every row is unlocked here and the selected one re-locked below (ref l.183/190).
-- The lock is what paints a selected row's name white: the template's
-- `<HighlightFont inherits="GameFontHighlight">` instance wins over the state
-- colour SetTextColor writes, which reaches the NORMAL instance only. It reaches
-- nothing else on a service row — the branch below blanks that row's fold glow.
button:UnlockHighlight()
if serviceType == "header" then
button:SetText(name)
button:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
button.subtext:SetText("")
if isExpanded then
button:SetNormalTexture("Interface\\Buttons\\UI-MinusButton-Up")
else
button:SetNormalTexture("Interface\\Buttons\\UI-PlusButton-Up")
end
button.foldHighlight:SetTexture("Interface\\Buttons\\UI-PlusButton-Hilight")
else
button:SetNormalTexture("")
button.foldHighlight:SetTexture("")
button:SetText(" " .. name)
if subText and subText ~= "" then
button.subtext:SetText(format(PARENS_TEMPLATE, subText))
else
button.subtext:SetText("")
end
-- The rank rides the NAME's right edge, wherever the name ended (ref l.157,
-- B253's flow row) — re-anchored every repaint because the name's width is
-- the thing that changes.
button.subtext:SetPoint("LEFT", getglobal(button:GetName() .. "Text"), "RIGHT", 10, 0)
if serviceType == "available" then
button:SetTextColor(0, 1.0, 0)
button.subR, button.subG, button.subB = 0, 0.6, 0
elseif serviceType == "used" then
button:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
button.subR, button.subG, button.subB = 0.5, 0.5, 0.5
else
button:SetTextColor(0.9, 0, 0)
button.subR, button.subG, button.subB = 0.6, 0, 0
end
button.subtext:SetTextColor(button.subR, button.subG, button.subB)
end
button.serviceIndex = index
button:Show()
else
button.serviceIndex = nil
button:UnlockHighlight()
button:SetText("")
button.subtext:SetText("")
button:SetNormalTexture("")
button.foldHighlight:SetTexture("")
button:Hide()
end
end
-- Selection highlight + detail (ref l.146-186). The glow rides the selected SERVICE row while
-- it is inside the visible window; a header selection shows no glow. The detail fills only for
-- a selected service.
local selection = GetTrainerSelectionIndex()
local selType
if selection >= 1 and selection <= numServices then
_, _, selType = GetTrainerServiceInfo(selection)
end
if selType and selType ~= "header"
and selection > skillOffset and selection <= skillOffset + BENILLA_TRAINER_ROWS then
local selButton = getglobal("BenillaTrainerService" .. (selection - skillOffset))
local hl = getglobal("BenillaTrainerSkillHighlight")
local tex = getglobal("BenillaTrainerSkillHighlightTexture")
if selType == "available" then
tex:SetVertexColor(0, 1.0, 0)
elseif selType == "used" then
tex:SetVertexColor(0.5, 0.5, 0.5)
else
tex:SetVertexColor(0.9, 0, 0)
end
hl:SetPoint("TOPLEFT", selButton, "TOPLEFT", 0, 0)
hl:Show()
-- The whole selected row goes white, and it is the ref's own pair of moves (l.183-184):
-- LockHighlight for the NAME (the button's label, so the white comes from its
-- HighlightFont instance, not from a repaint that the next Update would undo) and an
-- explicit whiten for the SUBTEXT, which is a child FontString the lock cannot reach.
-- An earlier revision called the white name a deliberate divergence "the ref does not
-- do"; the ref does it, through the lock this window had no engine method for at the
-- time (decision 1605). subR/subG/subB go white too so a hover-then-leave keeps the
-- subtext white.
selButton:LockHighlight()
selButton.subtext:SetTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
selButton.subR, selButton.subG, selButton.subB =
HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b
end
if selType and selType ~= "header" then
BenillaTrainer_ShowDetails(selection)
else
BenillaTrainer_HideDetails()
end
updateCollapseAllIcon(numServices)
end
-- The window's wheel handler (bubbled up the parent chain from a row) → one row via the faux kit.
function BenillaTrainerFrame_OnMouseWheel(delta)
BenillaFauxScrollFrame_OnMouseWheel(getglobal("BenillaTrainerListScrollFrame"), delta)
end
-- A row click: a header folds/unfolds its group (Collapse/ExpandTrainerSkillLine take the header's
-- display index); a service selects (ref ClassTrainerSkillButton_OnClick + SetSelection's header
-- branch). Left-button only.
function BenillaTrainerSkillButton_OnClick(button, mouseButton)
if mouseButton ~= "LeftButton" then return end
local index = button.serviceIndex
if not index then return end
local _, _, serviceType, isExpanded = GetTrainerServiceInfo(index)
if serviceType == "header" then
if isExpanded then
CollapseTrainerSkillLine(index)
else
ExpandTrainerSkillLine(index)
end
else
SelectTrainerService(index)
end
BenillaTrainerFrame_Update()
end
-- Hover recolors a service row's subtext to HIGHLIGHT, restoring its state colour on leave (ref
-- ClassTrainerSkillButton_OnEnter/OnLeave). Harmless over a header (its subtext is blank).
function BenillaTrainerSkillButton_OnEnter(button)
button.subtext:SetTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
end
function BenillaTrainerSkillButton_OnLeave(button)
button.subtext:SetTextColor(button.subR or 0.5, button.subG or 0.5, button.subB or 0.5)
end
-- ── The Train button ─────────────────────────────────────────────────────────────────────────
-- Buy the selected service, then repaint (the row flips to "used" once the app pushes the fresh
-- snapshot). The tradeskill profession-confirm dialog is a later refinement.
function BenillaTrainerTrainButton_OnClick()
BuyTrainerService(GetTrainerSelectionIndex())
BenillaTrainerFrame_Update()
end
-- ── The state-filter dropdown (ref ClassTrainerFrameFilterDropDown_*) ────────────────────────
function BenillaTrainerFilterDropDown_OnLoad()
UIDropDownMenu_Initialize(BenillaTrainerFilterDropDown, BenillaTrainerFilterDropDown_Initialize)
UIDropDownMenu_SetText(FILTER_LABEL, BenillaTrainerFilterDropDown)
UIDropDownMenu_SetWidth(96, BenillaTrainerFilterDropDown)
end
function BenillaTrainerFilterDropDown_Initialize()
local function addRow(kind, label, code)
local info = {}
info.text = code .. label .. CODE_CLOSE
info.value = kind
info.func = BenillaTrainerFilterDropDown_OnClick
info.checked = GetTrainerServiceTypeFilter(kind) and 1 or nil
info.keepShownOnClick = 1
UIDropDownMenu_AddButton(info)
end
addRow("available", LABEL_AVAILABLE, GREEN_CODE)
addRow("unavailable", LABEL_UNAVAILABLE, RED_CODE)
addRow("used", LABEL_USED, GRAY_CODE)
end
-- A row's func fires with the row button as `this`, carrying the PRE-click state (this.value /
-- this.checked): the kit's UIDropDownMenuButton_OnClick runs the func FIRST and only then, for a
-- keepShownOnClick row, flips the check texture + this.checked itself. So the func's whole job is
-- to flip the ENGINE filter to the inverse of the pre-click check, reset scroll, and repaint —
-- the visible check is the kit's to move, and the next open re-derives every row from
-- GetTrainerServiceTypeFilter anyway.
--
-- It must NOT re-Initialize the open menu here. Doing so re-ran AddButton, which sets
-- `button.checked` (and the Check texture) from the fresh engine state — so the kit's flip
-- immediately afterwards inverted the ALREADY-correct row and put it back. The check never
-- appeared to move, and because `this.checked` then read stale-true forever, a filter switched
-- off could never be switched back on.
function BenillaTrainerFilterDropDown_OnClick()
-- The engine filter AND the saved global, together: the global is what survives the
-- restart and what BenillaTrainerFrame_ApplyFilter pushes back after the per-packet
-- reset, so writing only the engine would lose the toggle at the next list (the ref's
-- own setglobal pair, Blizzard_TrainerUI.lua:526-530).
local on = 0
if not this.checked then
on = 1
end
setglobal("TRAINER_FILTER_" .. strupper(this.value), on)
SetTrainerServiceTypeFilter(this.value, on)
FauxScrollFrame_SetOffset(getglobal("BenillaTrainerListScrollFrame"), 0)
BenillaTrainerFrame_Update()
end
-- ── The collapse-all tab ─────────────────────────────────────────────────────────────────────
-- Fold / unfold every group (Collapse/ExpandTrainerSkillLine(0), ref
-- ClassTrainerCollapseAllButton_OnClick).
function BenillaTrainerCollapseAllButton_OnClick()
local b = getglobal("BenillaTrainerCollapseAllButton")
if b.collapsed then
b.collapsed = nil
ExpandTrainerSkillLine(0)
else
b.collapsed = 1
FauxScrollFrame_SetOffset(getglobal("BenillaTrainerListScrollFrame"), 0)
CollapseTrainerSkillLine(0)
end
BenillaTrainerFrame_Update()
end
-- ── The NPC-session lifecycle (MerchantFrame.xml's own shape) ────────────────────────────────
function BenillaTrainerFrame_OnLoad(frame)
-- The NPC portrait: the "npc" unit token → the trainer's live model bake
-- (crates/benilla/src/portrait, InteractNpc) — MerchantFrame's own binding.
SetPortraitTexture(getglobal(frame:GetName() .. "Portrait"), "npc")
frame:RegisterEvent("TRAINER_SHOW")
frame:RegisterEvent("TRAINER_UPDATE")
frame:RegisterEvent("TRAINER_CLOSED")
frame:RegisterEvent("PLAYER_MONEY")
-- The list repaint is wired in the scroll frame's own `<OnVerticalScroll>` below, the
-- reference's way; `updateFunc` was our kit's field and the reference has none (1860).
end
-- Push the three saved globals into the engine — the ref's ClassTrainerFrame <OnShow>
-- (Blizzard_TrainerUI.xml:531-533), and it is NOT redundant: the list builder rewrites the
-- engine's filter mask to its own default on every SMSG_TRAINER_LIST (byte-verified,
-- `0x4d75d9 mov ds:0xb73a1c,3`; decision 1128), so this is what puts the player's remembered
-- choice back. Called on show and at the top of TRAINER_SHOW — our repaint runs before
-- ShowUIPanel, so applying it only in OnShow would leave one frame painted under the reset.
function BenillaTrainerFrame_ApplyFilter()
SetTrainerServiceTypeFilter("available", TRAINER_FILTER_AVAILABLE)
SetTrainerServiceTypeFilter("unavailable", TRAINER_FILTER_UNAVAILABLE)
SetTrainerServiceTypeFilter("used", TRAINER_FILTER_USED)
end
-- The scroll frame's `<OnVerticalScroll>` lives in its own chunk and cannot see this
-- file's `local BENILLA_TRAINER_ROW_HEIGHT`, so the pitch is handed over here rather than
-- written as a literal in the XML (1860). The reference has the same shape with globals:
-- `QUESTLOG_QUEST_HEIGHT` and friends are file-scope globals in its own FrameXML.
function BenillaTrainerList_OnVerticalScroll()
FauxScrollFrame_OnVerticalScroll(BENILLA_TRAINER_ROW_HEIGHT, BenillaTrainerFrame_Update)
end
function BenillaTrainerFrame_SetTitle(name)
if name and name ~= "" then
getglobal("BenillaTrainerTitleText"):SetText(name)
else
getglobal("BenillaTrainerTitleText"):SetText("Trainer")
end
end
function BenillaTrainerFrame_OnEvent(frame)
if event == "TRAINER_SHOW" then
-- SHOW FIRST, then populate — the reference's own order, and not a style choice.
-- `FauxScrollFrame_Update` (the chain's since 1837) gates everything that sets the
-- bar's range on `frame:IsVisible()`, so a list repainted before its window is up
-- leaves `SetMinMaxValues` unset and both arrows dead. The reference relies on this
-- order hard enough to test it: `MerchantFrame.lua:22-27` does `ShowUIPanel(this)`
-- and then treats `not this:IsVisible()` as the panel system refusing, and closes.
-- Decision 1860.
ShowUIPanel(frame)
-- Fresh session: top of the list, a first selection (ref ClassTrainerFrame_Show).
BenillaTrainerFrame_ApplyFilter()
BenillaTrainerFrame_SetTitle(arg1)
FauxScrollFrame_SetOffset(getglobal("BenillaTrainerListScrollFrame"), 0)
if GetTrainerSelectionIndex() == 0 then
selectFirstService()
end
BenillaTrainerFrame_Update()
elseif event == "TRAINER_UPDATE" then
BenillaTrainerFrame_SetTitle(arg1)
-- Re-anchor the selection if it fell off the (possibly shorter) list.
if GetTrainerSelectionIndex() == 0 then
selectFirstService()
end
BenillaTrainerFrame_Update()
elseif event == "TRAINER_CLOSED" then
HideUIPanel(frame)
elseif event == "PLAYER_MONEY" then
BenillaMoney_Set("BenillaTrainerMoney", GetMoney())
-- A buy changes affordability without a TRAINER_UPDATE — repaint the detail's cost tint.
if BenillaTrainerFrame:IsVisible() then
BenillaTrainerFrame_Update()
end
end
end
function BenillaTrainerCloseButton_OnClick()
CloseTrainer()
HideUIPanel(getglobal("BenillaTrainerFrame"))
end
]]></Script>
<!--
One row (ref ClassTrainerSkillButtonTemplate): a 293×16 text row — the +/- fold slot at the left
(used for header rows, blanked per-row for services), the name, and the rank subtext beside it.
NO item icon (the list rows carry none; the spell icon is the DETAIL pane's). <OnLoad> wires the
child regions + the fold highlight onto `self` (the QuestLogFrame.xml row-template shape).
THE ROW IS A FLOW, NOT TWO COLUMNS (B253). The name carries **no width**, so it never wraps:
a FontString given an explicit width wraps at it, and a 16px row cannot grow — which is how
"Handstitched Leather Pants" came to draw two lines over the row beneath it. The reference's
own row (`ClassTrainerFrameTemplates.xml`, read off the player's chain, never transcribed)
makes the name the Button's `<ButtonText>` at width **0** and then SetPoints the subtext to
that text's RIGHT + 10 from Lua, per row — so a long name simply runs on and pushes its
"(Rank N)" along. We express the same law declaratively: no size on either string, the
subtext anchored to the name's right edge. The 160/188 fixed columns this replaces were
benilla's invention, and both halves of them were wrong — the cap wrapped, and the column
put the rank at a fixed x the reference never uses.
-->
<Button name="BenillaTrainerServiceTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
<!-- NO <Anchors> — Update SetPoints this to the row's own label every repaint, the
ref's own move (`skillSubText:SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`,
Blizzard_TrainerUI.lua l.157). A `relativeTo="$parentText"` here CANNOT work and
fails quietly: `<Layers>` is loader step 5 and the `<ButtonText>` is step 5b, so
the label does not exist yet and the anchor falls back to the row (the loader
does warn — `resolve_target`'s "does not resolve" line). The ref anchors it at
runtime for its own reason anyway: the rank must follow a name whose width is
only known once the name is measured (B253). -->
<FontString name="$parentSubText" inherits="GameFontNormalSmall" justifyH="LEFT"/>
</Layer>
</Layers>
<!-- THE SERVICE NAME IS THE BUTTON'S OWN ButtonText (the ref's `ClassTrainerSkill<n>Text`),
under the base's font TRIO — that is what turns a row white under the cursor and holds
the selected row white, with no script involved: `GameFontHighlight` is white, the engine
swaps the highlighted font instance in on hover, and `LockHighlight()` pins it for the
selection. It was a `$parentName` FontString until decision 1605, on a stale note that
`Button:SetTextColor`/`LockHighlight` had no engine method. Carries NO `<Size>` — B253's
flow row: the name measures to its own text and `$parentSubText` follows its right edge,
which is the reference's own `<ButtonText>`-at-width-0 plus per-row
`SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`. TradeSkillFrame.xml and CraftFrame.xml are
the twins (both fixed-column, so both keep an explicit width). -->
<ButtonText name="$parentText" justifyH="LEFT">
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="22" y="0"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>
<HighlightFont inherits="GameFontHighlight" justifyH="LEFT"/>
<DisabledFont inherits="GameFontDisable" justifyH="LEFT"/>
<!-- The +/- fold slot + its hover glow: meaningful for header rows (Update sets +/- there),
blanked for services. -->
<NormalTexture file="Interface\Buttons\UI-MinusButton-UP">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors>
</NormalTexture>
<HighlightTexture name="$parentHighlight" file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors>
</HighlightTexture>
<Scripts>
<OnLoad>
self.subtext = getglobal(self:GetName() .. "SubText")
self.foldHighlight = self:GetHighlightTexture()
</OnLoad>
<OnClick>BenillaTrainerSkillButton_OnClick(self, arg1)</OnClick>
<OnEnter>BenillaTrainerSkillButton_OnEnter(self)</OnEnter>
<OnLeave>BenillaTrainerSkillButton_OnLeave(self)</OnLeave>
</Scripts>
</Button>
<!--
A money coin slot (copied from MerchantFrame.xml's rig): BenillaMoney_Set fills three,
highest-denomination-first (ltr) or copper-first (rtl), shrinking each to its number + 13px coin.
-->
<Frame name="BenillaTrainerCoinTemplate" virtual="true">
<Size><AbsDimension x="13" y="13"/></Size>
<Layers><Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors></Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
</Layer></Layers>
</Frame>
<!-- A panel action button (Train/Exit): the UIPanelButtonTemplate face, label via text=. -->
<Button name="BenillaTrainerPanelButtonTemplate" virtual="true">
<Size><AbsDimension x="80" y="22"/></Size>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
</Button>
<Frame name="BenillaTrainerFrame" hidden="true">
<!-- The real 384×512 window. No <Anchors> — ShowUIPanel SetPoints this to the left slot. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<!-- NPC portrait 60×60 TOPLEFT (7,-6) (ref), BACKGROUND so the window art's ring frames it.
Bound in OnLoad via SetPortraitTexture(region, "npc") — the trainer's live model bake. -->
<Layer level="BACKGROUND">
<Texture name="$parentPortrait">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<!-- The four quadrant slabs that ARE the window art (ref): 256-wide left halves, 128-wide
right halves, each 256 tall, pinned to their corner, whole-texture. -->
<Layer level="BORDER">
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopLeft"><Size><AbsDimension x="256" y="256"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopRight"><Size><AbsDimension x="128" y="256"/></Size><Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-BotLeft"><Size><AbsDimension x="256" y="256"/></Size><Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-BotRight"><Size><AbsDimension x="128" y="256"/></Size><Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"/></Anchors></Texture>
<!-- Title: the NPC's name, fed as arg1 of TRAINER_SHOW/UPDATE via the NameCache; the
static "Trainer" shows until the creature-name query lands. -->
<FontString name="BenillaTrainerTitleText" inherits="GameFontNormal" justifyH="CENTER" text="Trainer">
<Anchors><Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</FontString>
<!-- Greeting (GetTrainerGreetingText), GameFontHighlight LEFT/TOP, 260×26, TOPLEFT (76,-38). -->
<FontString name="BenillaTrainerGreetingText" inherits="GameFontHighlight" justifyH="LEFT" justifyV="TOP">
<Size><AbsDimension x="260" y="26"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="76" y="-38"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
<!-- The horizontal divider between the list and the detail pane (ref): 256+75 wide, 16 tall,
TOPLEFT (15,-275), sampled from UI-ClassTrainer-HorizontalBar by TexCoord. -->
<Layer level="ARTWORK">
<Texture name="BenillaTrainerHorizontalBar" file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
<Size><AbsDimension x="256" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="15" y="-275"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="1.0" top="0" bottom="0.25"/>
</Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
<Size><AbsDimension x="75" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerHorizontalBar" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="0.29296875" top="0.25" bottom="0.5"/>
</Texture>
</Layer>
<!-- The FIXED detail-pane FontStrings (ref DetailScrollChildFrame), flattened onto this
window's own Layers (the flat-layout gap). Blanked/filled by Hide/ShowDetails.
THE WIDTHS ARE THE REFERENCE'S, not ours (B253). A FontString wraps at whatever
width it is given, so an invented narrow one is a wrap bug waiting for a long name:
the detail name at 150 broke "Handstitched Leather Pants" over two lines. The
reference's detail child is 296 wide with its name/requirements at **244** and its
description at **290**; our flattened anchors put the name at the same window x (68)
the child's 46 resolves to, so the same numbers land in the same place — and, unlike
the 290/300 they replace, they stay inside the pane instead of running past its
right edge at 358/330. -->
<Layer level="OVERLAY">
<FontString name="BenillaTrainerSkillName" inherits="GameFontNormal" justifyH="LEFT">
<Size><AbsDimension x="244" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="68" y="-293"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaTrainerSkillRequirements" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size><AbsDimension x="244" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerSkillName" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaTrainerCostLabel" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="30" y="-340"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaTrainerSkillDescription" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size><AbsDimension x="290" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerCostLabel" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The top-right close X (ref a UIPanelCloseButton at TOPRIGHT (-29,-8); the minimize-button
art stands in). -->
<Button name="BenillaTrainerCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="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>BenillaTrainerCloseButton_OnClick()</OnClick></Scripts>
</Button>
<!-- The expand/collapse-all tab (ref ClassTrainerExpandButtonFrame): a tab-shaped frame
carrying the collapse-all button — now functional (Collapse/ExpandTrainerSkillLine(0)). -->
<Frame name="BenillaTrainerExpandButtonFrame">
<Size><AbsDimension x="54" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="15" y="-70"/></Offset></Anchor></Anchors>
<Layers><Layer level="BACKGROUND">
<Texture name="BenillaTrainerExpandTabLeft" file="Interface\ClassTrainerFrame\UI-ClassTrainer-ExpandTab-Left"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors></Texture>
<Texture name="BenillaTrainerExpandTabMiddle" file="Interface\QuestFrame\UI-QuestLogSortTab-Middle"><Size><AbsDimension x="38" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerExpandTabLeft" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
<Texture file="Interface\QuestFrame\UI-QuestLogSortTab-Right"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerExpandTabMiddle" relativePoint="RIGHT"/></Anchors></Texture>
</Layer></Layers>
<Frames>
<Button name="BenillaTrainerCollapseAllButton" text="All">
<Size><AbsDimension x="40" y="22"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerExpandTabLeft" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-MinusButton-Up"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"/></Anchors></NormalTexture>
<HighlightTexture file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"/></Anchors></HighlightTexture>
<ButtonText><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="18" y="0"/></Offset></Anchor></Anchors></ButtonText>
<NormalFont inherits="GameFontNormalSmall" justifyH="LEFT"/>
<Scripts><OnClick>BenillaTrainerCollapseAllButton_OnClick()</OnClick></Scripts>
</Button>
</Frames>
</Frame>
<!-- The state-filter dropdown (ref ClassTrainerFrameFilterDropDown): the UIDropDownMenu capsule
whose three keepShownOnClick check rows drive the engine's state-visibility filter —
replacing the three inline sort checkboxes. TOPRIGHT (-26,-64), the ref's spot. -->
<Frame name="BenillaTrainerFilterDropDown" inherits="UIDropDownMenuTemplate">
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-26" y="-64"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>BenillaTrainerFilterDropDown_OnLoad()</OnLoad>
<OnShow>BenillaTrainerFilterDropDown_OnLoad()</OnShow>
</Scripts>
</Frame>
<!-- The persistent selection highlight (ref ClassTrainerSkillHighlightFrame): 293×16,
ARTWORK-layer UI-Listbox-Highlight2. Declared BEFORE the rows so the row text paints OVER
the glow (same parent + level ⇒ declaration order is the z-tiebreak). Moved onto the
selected visible service row + vertex-colored by state. -->
<Frame name="BenillaTrainerSkillHighlight" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers><Layer level="ARTWORK">
<Texture name="BenillaTrainerSkillHighlightTexture" file="Interface\Buttons\UI-Listbox-Highlight2">
<Anchors><Anchor point="TOPLEFT"/><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
</Layer></Layers>
</Frame>
<!-- 11 tree rows (ref ClassTrainerSkill1..11): 293×16, row1 TOPLEFT (22,-100); rows 2-11 chain
TOPLEFT→prior BOTTOMLEFT with no gap. -->
<Button name="BenillaTrainerService1" inherits="BenillaTrainerServiceTemplate">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-100"/></Offset></Anchor></Anchors>
</Button>
<Button name="BenillaTrainerService2" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService1" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService3" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService2" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService4" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService3" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService5" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService4" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService6" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService5" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService7" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService6" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService8" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService7" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService9" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService8" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService10" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService9" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService11" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService10" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<!-- The faux-scroll frame (`FauxScrollFrameTemplate`, the REFERENCE's since 1837):
invisible container over the 11 rows; only its right-hand Slider scroll bar renders.
Sized to the 11-row span so the bar sits in the window's right gutter.
THE OWNER DECLARES `<OnVerticalScroll>`, which is how the reference's kit works and
the one thing our transcription did differently: ours carried a `frame.updateFunc`
field that `BenillaFauxScrollBar_OnValueChanged` read, and the reference has no such
field. Its chain is the bar's own OnValueChanged
(`this:GetParent():SetVerticalScroll(arg1)`, UIPanelTemplates.xml) -> this handler ->
`FauxScrollFrame_OnVerticalScroll(itemHeight, updateFunction)`, which is the only
thing that writes `frame.offset`. Without it the arrows and the thumb move the bar
and the list never follows (1860). 16 is the row pitch
(`BenillaTrainerServiceTemplate`). -->
<ScrollFrame name="BenillaTrainerListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="176"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-100"/></Offset></Anchor></Anchors>
<Scripts>
<OnVerticalScroll>BenillaTrainerList_OnVerticalScroll()</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The detail spell icon (ref ClassTrainerSkillIcon): a 37×37 button over a 64×64 UI-EmptySlot
ring, at the detail area's top-left. Icon via SetNormalTexture; Hide()n when unselected.
The hover is the ref's own, verbatim (Blizzard_TrainerUI.xml:452-459): owner is the icon
button, anchor ANCHOR_RIGHT, subject `ClassTrainerFrame.selectedService` — the SELECTED
row, which is why the tooltip describes the selection no matter which row the mouse
passed over on its way here. GetTrainerSelectionIndex() is our engine-held equivalent of
that field; it reads 0 with nothing selected, and SetTrainerService no-ops on 0. This is
the ONLY SetTrainerService call site in the shipped client — the list rows carry no
tooltip of their own (their template's OnEnter only recolours the subtext). -->
<Button name="BenillaTrainerSkillIcon" hidden="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="26" y="-291"/></Offset></Anchor></Anchors>
<Layers><Layer level="BACKGROUND">
<Texture file="Interface\Buttons\UI-EmptySlot"><Size><AbsDimension x="64" y="64"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="-13" y="13"/></Offset></Anchor></Anchors></Texture>
</Layer></Layers>
<Scripts>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetTrainerService(GetTrainerSelectionIndex())
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Button>
<!-- The detail cost coins (ref ClassTrainerDetailMoneyFrame), left-to-right off the "Cost:"
label; BenillaMoney_Set("BenillaTrainerDetailMoney", cost, true) fills them. Coin1's LEFT
pins to the label's RIGHT (not a magic absolute x): each coin auto-shrinks to numW+13 and
its number left-aligns at this LEFT edge, growing rightward — so the number abuts the
":" with a fixed 4px gap regardless of the amount's digit count (the ref's cost-label →
money-frame anchoring; the old absolute x reserved a wide 1-vs-2-digit gap). -->
<Frame name="BenillaTrainerDetailMoneyCoin1" inherits="BenillaTrainerCoinTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerCostLabel" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="BenillaTrainerDetailMoneyCoin2" inherits="BenillaTrainerCoinTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerDetailMoneyCoin1" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="BenillaTrainerDetailMoneyCoin3" inherits="BenillaTrainerCoinTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerDetailMoneyCoin2" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
</Frame>
<!-- The player purse (ref ClassTrainerMoneyFrame): the rightmost coin (Coin1) pins its
BOTTOMRIGHT to the window BOTTOMLEFT + (180, 86); Coin2/Coin3 chain left. Filled
copper-first by BenillaMoney_Set("BenillaTrainerMoney", GetMoney()). -->
<Frame name="BenillaTrainerMoneyCoin1" inherits="BenillaTrainerCoinTemplate">
<Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="180" y="86"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="BenillaTrainerMoneyCoin2" inherits="BenillaTrainerCoinTemplate">
<Anchors><Anchor point="RIGHT" relativeTo="BenillaTrainerMoneyCoin1" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="BenillaTrainerMoneyCoin3" inherits="BenillaTrainerCoinTemplate">
<Anchors><Anchor point="RIGHT" relativeTo="BenillaTrainerMoneyCoin2" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
</Frame>
<!-- The Train button (ref ClassTrainerTrainButton): UIPanelButton face + "Train", 80×22, in
the detail area's bottom-right. Enabled only for an affordable available service. -->
<Button name="BenillaTrainerTrainButton" inherits="BenillaTrainerPanelButtonTemplate" text="Train">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="184" y="-409"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaTrainerTrainButton_OnClick()</OnClick></Scripts>
</Button>
<!-- The Exit button (ref ClassTrainerCancelButton): hides the window; OnHide fires CloseTrainer(). -->
<Button name="BenillaTrainerCancelButton" inherits="BenillaTrainerPanelButtonTemplate" text="Exit">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="265" y="-409"/></Offset></Anchor></Anchors>
<Scripts><OnClick>HideUIPanel(getglobal("BenillaTrainerFrame"))</OnClick></Scripts>
</Button>
</Frames>
<!-- The NPC-session lifecycle (MerchantFrame.xml's shape): OnHide calls CloseTrainer() — it ends
the client-side session whenever this window hides for ANY reason (ESC, close X, Exit,
TRAINER_CLOSED, or another left-slot window displacing it). The list wheel lives on the
WINDOW (the engine bubbles a spin up the parent chain, so a spin over a row reaches here). -->
<Scripts>
<OnLoad>BenillaTrainerFrame_OnLoad(self)</OnLoad>
<OnEvent>BenillaTrainerFrame_OnEvent(self)</OnEvent>
<OnMouseWheel>BenillaTrainerFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>BenillaTrainerFrame_ApplyFilter(); PlaySound("igCharacterInfoOpen")</OnShow>
<OnHide>CloseTrainer(); PlaySound("igCharacterInfoClose")</OnHide>
</Scripts>
</Frame>
</Ui>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-885
View File
@@ -1,885 +0,0 @@
<!--
The unit right-click popups (decision 0434 phase 5) — benilla's own implementation of the
engine the 1.12 FrameXML `UnitPopup.lua` SPECIFIES. That file is the specification this one
matches, not the source of this text. What is faithful is everything observable: the row
catalogue and every row's facts (label, interact-distance class, colours, atlas cells), the
membership and ORDER of every menu, which rows a menu hides and when, which rows grey out and
when, and what each row does when it is clicked. How that is written is ours — a row
catalogue walked by a builder, and three keyed tables (hide / grey / click) where the
reference runs one long `elseif value == "…"` ladder each. The globals the reference
publishes (`UnitPopupButtons`, `UnitPopupMenus`, `UnitPopupShown`, `UnitPopup_ShowMenu`, …)
keep their names and shapes exactly, because addons index, call and replace them by name.
The menus ride the dropdown kit's MENU displayMode + level-2 submenus (UIDropDownMenu.xml).
The owner frames (TargetFrameDropDown, PlayerFrameDropDown, PartyMemberFrameNDropDown) live
with their frames in UnitFrames.xml / PartyFrame.xml — this file is the shared engine, like
the ref's load split. The OnUpdate driver frame at the bottom stands in for the ref's
UIParent.xml OnUpdate hook (the DeathFrame hidden-driver pattern; benilla's UIParent carries
no OnUpdate).
DEVIATIONS, deliberate (each waits for its arc):
- NO ADDED ROWS. A "PVP" row briefly sat at the head of SELF (decision 0646 §3); decision 0652
took it back out. 1.12.1 has no PvP toggle in any menu — `TogglePVP()`'s single caller in the
whole shipped UI is `SlashCmdList["PVP"]`, and `UnitPopup.lua` contains the string "PVP"
exactly zero times — so the row was a deviation that made this menu read wrong against the
reference. `/pvp` is the toggle the client has.
- DEFERRED ENTRIES: rows whose backing system doesn't exist yet are HIDDEN outright (the
BENILLA_UNITPOPUP_DEFERRED set below) rather than shown-dead: TARGET (the friends
list). TRADE came out
with decision 0592 P1, DUEL with 0633, INSPECT with 0631, GUILD_PROMOTE/GUILD_LEAVE with
the guild arc (1257 — their hide predicates and click rows are below), RESET_INSTANCES with
the instance-lockout arc (1748 — its predicate is the reference's own
`CanShowResetInstances()`, whose four terms are now real), FOLLOW with 0890's
follow-up — and
that last entry's stated reason ("its CMSG family is its own slice") was simply WRONG: 0890
verified auto-follow sends nothing at all, it is pure client-side movement, so the row was
waiting on a wire slice that does not exist. It was the motion law it really needed.
Everything a menu SHOWS works. The PET menu opened with decision 1066: its four rows and the
one predicate that forks them are below, its opener is UnitFrames.xml's PetFrameDropDown, and
the two dialogs behind Abandon and Rename are registered in PetActionBar.xml (with the pet
arc, like every other dialog in this codebase — this file must stay loadable without the
popup engine). The RAID_* rank rows opened with decision 1549: their hide-always predicate is
gone, they carry the reference's own per-row rules and click handlers, and their opener is
RaidFrame.xml's row right-click.
- Lua 5.1: the ref's 5.0 `for i, v in t do` iteration is ipairs/pairs here; strsub is
string.sub. `TEXT` is kept because the reference publishes it as the localization hook every
displayed string passes through (it is the identity, and BasicControls.xml defines the real
one) — row labels are declared through it here for the same reason.
- The newbie tooltips on rows are OUT with the kit's (default-off cvar look).
- CheckInteractDistance now really gates (decision 0631): the binding landed with the inspect
row, off wow-re's §5-VERIFIED `check_interact_dist2` table {10², 11.1111², 10², 30²}. So every
SHOWN row with a non-zero `dist` grays out of range now — INSPECT (dist 1, 10 yd), TRADE
(dist 2, 11.11 yd) and DUEL (dist 3, 10 yd) — where TRADE and DUEL shipped ungated only
because the binding was nil when they landed. The nil-guard in the enable pass below is
kept as-is.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The localization identity every displayed string passes through (BasicControls.xml
-- defines the real one; this stands in when it is not loaded).
if ( not TEXT ) then
TEXT = function(s) return s end
end
UNITPOPUP_TIMEOUT = 5;
-- ── The row catalogue ──────────────────────────────────────────────────────────────
-- `UnitPopupButtons` is the flat key → row map the reference publishes: addons index it,
-- and overwrite entries in it, by name. Each row is declared once, from its key, the
-- label to draw, and its interact-distance class — 0 for no range check at all, 1..4 for
-- the client's own CheckInteractDistance classes, which the enable pass consults. Extra
-- presentation attributes (a leading spacer, a submenu arrow, a check box, a colour, an
-- icon and its atlas cell) ride in the fourth field and are copied through untouched.
UnitPopupButtons = { };
local function declareRow(key, label, dist, attributes)
local row = attributes or { };
row.text = TEXT(label);
row.dist = dist or 0;
UnitPopupButtons[key] = row;
end
-- { key, label, interact-distance class, extra attributes }
local ROWS = {
{ "CANCEL", CANCEL, 0, { space = 1 } },
{ "TRADE", TRADE, 2 },
{ "INSPECT", INSPECT, 1 },
{ "TARGET", TARGET },
{ "DUEL", DUEL, 3, { space = 1 } },
{ "WHISPER", WHISPER },
{ "INVITE", PARTY_INVITE },
{ "UNINVITE", PARTY_UNINVITE },
{ "PROMOTE", PARTY_PROMOTE },
{ "GUILD_PROMOTE", GUILD_PROMOTE },
{ "GUILD_LEAVE", GUILD_LEAVE },
{ "LEAVE", PARTY_LEAVE },
{ "FOLLOW", FOLLOW, 4 },
-- The pet verbs. Only the last four are reachable — no 1.12 menu holds the six
-- stance/command rows above them (the reference declares them and builds no menu
-- from them either), so nothing here can click them. They stay declared because the
-- catalogue is what addons index.
{ "PET_PASSIVE", PET_PASSIVE },
{ "PET_DEFENSIVE", PET_DEFENSIVE },
{ "PET_AGGRESSIVE", PET_AGGRESSIVE },
{ "PET_WAIT", PET_WAIT },
{ "PET_FOLLOW", PET_FOLLOW },
{ "PET_ATTACK", PET_ATTACK },
{ "PET_DISMISS", PET_DISMISS },
{ "PET_ABANDON", PET_ABANDON },
{ "PET_PAPERDOLL", PET_PAPERDOLL },
{ "PET_RENAME", PET_RENAME },
-- Loot: the parent row opens a submenu, the five below it are that submenu.
{ "LOOT_METHOD", LOOT_METHOD, 0, { nested = 1 } },
{ "FREE_FOR_ALL", LOOT_FREE_FOR_ALL },
{ "ROUND_ROBIN", LOOT_ROUND_ROBIN },
{ "MASTER_LOOTER", LOOT_MASTER_LOOTER },
{ "GROUP_LOOT", LOOT_GROUP_LOOT },
{ "NEED_BEFORE_GREED", LOOT_NEED_BEFORE_GREED },
{ "RESET_INSTANCES", RESET_INSTANCES },
-- The quality floor, likewise — and each threshold row wears its own quality colour.
{ "LOOT_THRESHOLD", LOOT_THRESHOLD, 0, { nested = 1 } },
{ "LOOT_PROMOTE", LOOT_PROMOTE },
{ "ITEM_QUALITY2_DESC", ITEM_QUALITY2_DESC, 0, { color = ITEM_QUALITY_COLORS[2] } },
{ "ITEM_QUALITY3_DESC", ITEM_QUALITY3_DESC, 0, { color = ITEM_QUALITY_COLORS[3] } },
{ "ITEM_QUALITY4_DESC", ITEM_QUALITY4_DESC, 0, { color = ITEM_QUALITY_COLORS[4] } },
-- The raid rank rows. Their labels are the plain verbs, not RAID_-prefixed strings.
{ "RAID_LEADER", NEW_LEADER },
{ "RAID_PROMOTE", PROMOTE },
{ "RAID_DEMOTE", DEMOTE },
{ "RAID_REMOVE", REMOVE },
-- The marks' parent row; the eight marks themselves are declared below, and clearing
-- the mark is a row of its own.
{ "RAID_TARGET_ICON", RAID_TARGET_ICON, 0, { nested = 1 } },
{ "RAID_TARGET_NONE", NONE, 0, { checkable = 1 } },
};
for _, spec in ipairs(ROWS) do
declareRow(spec[1], spec[2], spec[3], spec[4]);
end
-- The eight marks ride ONE atlas: four quarter-cells across, two down, filled left to
-- right and top to bottom in mark order. So a mark's cell follows from its number, and
-- the only facts worth writing per mark are its name and its colour.
local RAID_MARK_TEXTURE = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
local RAID_MARK_CELL = 0.25;
local RAID_MARKS = {
{ RAID_TARGET_1, 1.0, 0.92, 0 },
{ RAID_TARGET_2, 0.98, 0.57, 0 },
{ RAID_TARGET_3, 0.83, 0.22, 0.9 },
{ RAID_TARGET_4, 0.04, 0.95, 0 },
{ RAID_TARGET_5, 0.7, 0.82, 0.875 },
{ RAID_TARGET_6, 0, 0.71, 1 },
{ RAID_TARGET_7, 1.0, 0.24, 0.168 },
{ RAID_TARGET_8, 0.98, 0.98, 0.98 },
};
for mark, spec in ipairs(RAID_MARKS) do
local cell = mark - 1;
local atlasRow = math.floor(cell / 4);
local atlasColumn = cell - atlasRow * 4;
declareRow("RAID_TARGET_" .. mark, spec[1], 0, {
checkable = 1,
color = { r = spec[2], g = spec[3], b = spec[4] },
icon = RAID_MARK_TEXTURE,
tCoordLeft = atlasColumn * RAID_MARK_CELL,
tCoordRight = (atlasColumn + 1) * RAID_MARK_CELL,
tCoordTop = atlasRow * RAID_MARK_CELL,
tCoordBottom = (atlasRow + 1) * RAID_MARK_CELL,
});
end
-- ── The menus ──────────────────────────────────────────────────────────────────────
-- Which rows a menu holds, and in what order. This IS the menu's observable shape, so
-- both the membership and the order are the reference's exactly. The last two are the
-- level-2 submenus, keyed by the row that opens them.
UnitPopupMenus = {
SELF = { "LOOT_METHOD", "LOOT_THRESHOLD", "LOOT_PROMOTE", "LEAVE", "RESET_INSTANCES", "RAID_TARGET_ICON", "CANCEL" },
PET = { "PET_PAPERDOLL", "PET_RENAME", "PET_ABANDON", "PET_DISMISS", "CANCEL" },
PARTY = { "WHISPER", "PROMOTE", "LOOT_PROMOTE", "UNINVITE", "INSPECT", "TRADE", "FOLLOW", "DUEL", "RAID_TARGET_ICON", "CANCEL" },
PLAYER = { "WHISPER", "INSPECT", "INVITE", "TRADE", "FOLLOW", "DUEL", "RAID_TARGET_ICON", "CANCEL" },
RAID = { "RAID_LEADER", "RAID_PROMOTE", "RAID_DEMOTE", "RAID_REMOVE", "CANCEL" },
FRIEND = { "WHISPER", "INVITE", "TARGET", "GUILD_PROMOTE", "GUILD_LEAVE", "CANCEL" },
RAID_TARGET_ICON = { "RAID_TARGET_1", "RAID_TARGET_2", "RAID_TARGET_3", "RAID_TARGET_4", "RAID_TARGET_5", "RAID_TARGET_6", "RAID_TARGET_7", "RAID_TARGET_8", "RAID_TARGET_NONE" },
LOOT_METHOD = { "FREE_FOR_ALL", "ROUND_ROBIN", "MASTER_LOOTER", "GROUP_LOOT", "NEED_BEFORE_GREED", "CANCEL" },
LOOT_THRESHOLD = { "ITEM_QUALITY2_DESC", "ITEM_QUALITY3_DESC", "ITEM_QUALITY4_DESC", "CANCEL" },
};
-- Row N of the menu being opened is shown (1) or hidden (0). This is only a seed — the
-- hide pass rewrites every entry it walks, and grows the table for a menu longer than
-- the reference's nine (PARTY's ten rows do exactly that).
UnitPopupShown = { 1, 1, 1, 1, 1, 1, 1, 1, 1 };
-- The row that PICKS each loot method → the token the API speaks. Used twice: to gate the
-- five submenu rows, and to send the choice.
local LOOT_METHOD_TOKENS = {
FREE_FOR_ALL = "freeforall",
ROUND_ROBIN = "roundrobin",
MASTER_LOOTER = "master",
GROUP_LOOT = "group",
NEED_BEFORE_GREED = "needbeforegreed",
};
-- The label the parent Loot Method row wears, keyed by the token GetLootMethod() answers
-- with. (The reference's newbie tooltips are out with the kit's.)
UnitLootMethod = {
freeforall = { text = LOOT_FREE_FOR_ALL },
roundrobin = { text = LOOT_ROUND_ROBIN },
master = { text = LOOT_MASTER_LOOTER },
group = { text = LOOT_GROUP_LOOT },
needbeforegreed = { text = LOOT_NEED_BEFORE_GREED },
};
-- The dropdowns whose open menu this engine owns — the enable pass ignores anybody
-- else's. (FriendsDropDown is ItemRef.xml's stand-in and drives its menu directly.)
UnitPopupFrames = {
"PlayerFrameDropDown",
"TargetFrameDropDown",
"PetFrameDropDown",
"PartyMemberFrame1DropDown",
"PartyMemberFrame2DropDown",
"PartyMemberFrame3DropDown",
"PartyMemberFrame4DropDown",
};
-- The DEFERRED set (header): entries hidden until their backing arc lands.
BENILLA_UNITPOPUP_DEFERRED = {
TARGET = 1,
};
-- ── Shared helpers ─────────────────────────────────────────────────────────────────
-- The marks and the row that opens them share one key prefix, and three passes ask the
-- same question, so it gets a name. RAID_TARGET_ICON answers YES on purpose: the parent
-- row is leader business exactly like the marks under it.
local function isRaidMarkRow(key)
return string.sub(key, 1, 12) == "RAID_TARGET_";
end
-- The party facts the hide and enable passes both ask about, read once per pass instead
-- of once per row. `canCoop` needs the menu's unit, so it is only meaningful when one is
-- passed (the enable pass never asks).
local function partyState(menu)
local party = {
inParty = 0,
isLeader = 0,
isAssistant = 0,
canCoop = 0,
};
if ( (GetNumPartyMembers() > 0) or (GetNumRaidMembers() > 0) ) then
party.inParty = 1;
end
if ( party.inParty == 1 and IsPartyLeader() ) then
party.isLeader = 1;
end
if ( IsRaidOfficer() ) then
party.isAssistant = 1;
end
if ( menu and menu.unit and UnitCanCooperate("player", menu.unit) ) then
party.canCoop = 1;
end
return party;
end
-- Record which menu a level is showing, so the enable pass can find it again.
local function rememberOpenMenu(level, menu)
OPEN_DROPDOWNMENUS[level] = { which = menu.which, unit = menu.unit };
end
-- The dropdown kit wants a text colour as three separate fields, and a row that has no
-- colour must leave all three alone.
local function applyTextColor(info, color)
if ( color ) then
info.textR, info.textG, info.textB = color.r, color.g, color.b;
end
end
-- One catalogue row → one dropdown info table. Both menu levels want the same
-- presentation off a row, so it is read in one place; each caller adds what only its own
-- level decides (the owner, the checked state, the submenu arrow). Returns the row
-- alongside, since callers go on to ask it questions.
local function rowInfo(key)
local row = UnitPopupButtons[key];
local info = {
text = row.text,
value = key,
func = UnitPopup_OnClick,
icon = row.icon,
tCoordLeft = row.tCoordLeft,
tCoordRight = row.tCoordRight,
tCoordTop = row.tCoordTop,
tCoordBottom = row.tCoordBottom,
};
applyTextColor(info, row.color);
return info, row;
end
-- ── Which rows a menu shows at all ─────────────────────────────────────────────────
-- Every row that can be hidden owns one predicate answering "hide this?" from the menu
-- being opened plus the party facts. A row with no predicate is always shown. The
-- conditions are the reference's, row for row; only the ladder is gone.
local HIDE_ROW = { };
-- You cannot trade with, duel, inspect or follow someone you cannot cooperate with — an
-- enemy-faction player is already attackable, so none of the four has anything to offer.
-- (Ref UnitPopup.lua l.285/304-307/311-319. INSPECT and FOLLOW were MISSING until
-- decision 1033: without them both stayed shown for *every* unit — including one that
-- does not exist — so PARTY's shown-count never fell to zero, ShowMenu's "nothing but
-- CANCEL, don't show the menu" early-out never fired at load, and each party dropdown's
-- OnLoad-time initialize ran on to its closing PlaySound.)
local function hideWithoutCooperation(menu, party)
return party.canCoop == 0;
end
HIDE_ROW["TRADE"] = hideWithoutCooperation;
HIDE_ROW["DUEL"] = hideWithoutCooperation;
HIDE_ROW["INSPECT"] = hideWithoutCooperation;
HIDE_ROW["FOLLOW"] = hideWithoutCooperation;
-- Nothing to say to yourself, or to someone you cannot cooperate with.
HIDE_ROW["WHISPER"] = function(menu, party)
return (menu.unit and party.canCoop == 0) or menu.name == UnitName("player");
end
-- The units an invite would be pointless against: the party we already have, and us.
local INVITE_POINTLESS_AGAINST = { "party1", "party2", "party3", "party4", "player" };
HIDE_ROW["INVITE"] = function(menu, party)
if ( menu.unit ) then
return party.canCoop == 0;
end
-- Addressed by NAME (a chat name, a /who row): no unit to test, so compare names.
for _, unit in ipairs(INVITE_POINTLESS_AGAINST) do
if ( menu.name == UnitName(unit) ) then
return true;
end
end
end
-- Party bookkeeping rows: some need a party at all, some need to be leading it.
local function hideOutsideAParty(menu, party)
return party.inParty == 0;
end
HIDE_ROW["LEAVE"] = hideOutsideAParty;
HIDE_ROW["LOOT_METHOD"] = hideOutsideAParty;
HIDE_ROW["LOOT_THRESHOLD"] = hideOutsideAParty;
-- "Reset all instances", ref UnitPopup.lua l.377-380 (decision 1748) — TWO conditions,
-- and the second is not `hideUnlessLeading`: a SOLO player may reset (there is no party
-- to hold anyone inside), a party LEADER may, and a party member may not. The engine half
-- is the interesting one: `CanShowResetInstances()` is true only while we hold a
-- permanent bind, are not standing in a 5-man, and left one within the last 25 hours.
HIDE_ROW["RESET_INSTANCES"] = function(menu, party)
return not CanShowResetInstances() or (party.inParty == 1 and party.isLeader == 0);
end
local function hideUnlessLeading(menu, party)
return party.inParty == 0 or party.isLeader == 0;
end
HIDE_ROW["PROMOTE"] = hideUnlessLeading;
HIDE_ROW["UNINVITE"] = hideUnlessLeading;
-- The five loot-method rows: in a party always, and either we set the method or this row
-- IS the method already in force — so a member still gets to SEE which one is on.
for rowKey, method in pairs(LOOT_METHOD_TOKENS) do
HIDE_ROW[rowKey] = function(menu, party)
return party.inParty == 0 or (party.isLeader == 0 and GetLootMethod() ~= method);
end
end
-- Handing master loot over: only a leader under master loot, and never to whoever
-- already holds it (party member or, at index 0, us).
HIDE_ROW["LOOT_PROMOTE"] = function(menu, party)
local lootMethod, lootMaster = GetLootMethod();
return party.inParty == 0 or party.isLeader == 0 or lootMethod ~= "master"
or (lootMaster and menu.unit == "party" .. lootMaster)
or (menu.unit == "player" and lootMaster and lootMaster == 0);
end
-- The pet menu forks on ONE predicate, and Dismiss reads it INVERTED (decision 1066,
-- ref UnitPopup.lua l.402-417): a hunter's pet can be abandoned, so it offers
-- paperdoll/rename/abandon and hides dismiss; a warlock's demon cannot, so it offers only
-- dismiss. Rename wants both bits, because its own is one-shot — the server clears it
-- after the first rename.
local function hideUnlessAbandonable()
return not PetCanBeAbandoned();
end
HIDE_ROW["PET_PAPERDOLL"] = hideUnlessAbandonable;
HIDE_ROW["PET_ABANDON"] = hideUnlessAbandonable;
HIDE_ROW["PET_RENAME"] = function()
return not PetCanBeAbandoned() or not PetCanBeRenamed();
end
HIDE_ROW["PET_DISMISS"] = function()
return PetCanBeAbandoned();
end
-- ── The four raid rank rows (decision 1549, ref UnitPopup.lua l.382-401) ──────────────
--
-- Every one of them re-reads the CLICKED ROW's rank through `GetRaidRosterInfo(userData)`
-- — which is why the RAID menu is the only one in this file opened with a fourth
-- argument, and why that argument is the raid ROW INDEX rather than a name. The rules,
-- ref-verbatim:
--
-- New Leader leader only, and not on the leader themself (rank 2)
-- Promote leader only, and only on a plain member (rank 0)
-- Demote leader only, and only on an assistant (rank 1)
-- Remove leader OR assistant, and never on the leader
--
-- The first three also require `menu.name`: a row whose name has not resolved cannot be
-- addressed by the by-name bindings behind them. Remove does not, because it acts on the
-- index directly.
local function raidRank(menu)
local _, rank = GetRaidRosterInfo(menu.userData);
return rank;
end
-- `party.isLeader` is 0/1, not a boolean, and **0 is truthy in Lua** — so these compare
-- against 0 rather than saying `not party.isLeader`, exactly as every other predicate in
-- this file does and exactly as the reference's own `(isLeader == 0)` does.
HIDE_ROW["RAID_LEADER"] = function(menu, party)
return party.isLeader == 0 or raidRank(menu) == 2 or not menu.name;
end
HIDE_ROW["RAID_PROMOTE"] = function(menu, party)
return party.isLeader == 0 or raidRank(menu) ~= 0 or not menu.name;
end
HIDE_ROW["RAID_DEMOTE"] = function(menu, party)
return party.isLeader == 0 or raidRank(menu) ~= 1 or not menu.name;
end
HIDE_ROW["RAID_REMOVE"] = function(menu, party)
return (party.isLeader == 0 and party.isAssistant == 0) or raidRank(menu) == 2;
end
-- The two GUILD rows, ref UnitPopup.lua l.323-330 (decision 1257's guild arc un-defers
-- them). Both gate on `GuildFrame:IsVisible()`, which is the whole point of them: these
-- rows exist for right-clicking a GUILD ROSTER row, not a unit, so they are hidden
-- everywhere else — a friend-list or /who row opens the same FRIEND menu and must not
-- offer them.
-- · GUILD_PROMOTE — hand over the guild. Guild master only, and never on yourself.
-- · GUILD_LEAVE — leave the guild. Only ever offered on YOURSELF.
-- `GuildFrame and` is a nil-guard, not a behaviour change: FriendsFrame.xml loads after
-- this file in the manifest, so in a real session the frame always exists and the
-- condition is exactly the reference's. It is here because this file must stay usable
-- when loaded on its own (the same reason FriendsFrame.xml guards `UpdateMicroButtons`).
HIDE_ROW["GUILD_PROMOTE"] = function(menu)
return not IsGuildLeader() or menu.name == UnitName("player")
or not (GuildFrame and GuildFrame:IsVisible());
end
HIDE_ROW["GUILD_LEAVE"] = function(menu)
return menu.name ~= UnitName("player")
or not (GuildFrame and GuildFrame:IsVisible());
end
-- Marking is leader/assistant business, and a mark on an enemy-faction player nobody in
-- the group can cooperate with is pointless — except on our OWN menu, which marks us.
local function hideRaidMarkRow(menu, party)
if ( party.inParty == 0 or (party.isLeader == 0 and party.isAssistant == 0) ) then
return true;
end
if ( menu.which ~= "SELF" and UnitExists("target") and not UnitInParty("target") ) then
return UnitIsPlayer("target")
and not UnitCanCooperate("player", "target")
and not UnitIsUnit("target", "player");
end
end
-- A row's own predicate, or the shared one every raid-mark row uses (the reference keys
-- those by PREFIX, so an addon's own mark row is covered too).
local function hidePredicate(key)
local hide = HIDE_ROW[key];
if ( hide ) then
return hide;
end
if ( isRaidMarkRow(key) ) then
return hideRaidMarkRow;
end
end
function UnitPopup_ShowMenu(dropdownMenu, which, unit, name, userData)
-- Wake the enable-pass driver: it hides itself the moment no unit menu of ours is
-- open (UnitPopup_OnUpdate's early-out), and this function is the one opener of
-- every unit menu — the six dropdowns' initialize functions all land here.
BenillaUnitPopupDriver:Show();
dropdownMenu.which = which;
dropdownMenu.unit = unit;
if ( unit and not name ) then
name = UnitName(unit);
end
dropdownMenu.name = name;
dropdownMenu.userData = userData;
-- No realm half: benilla has no cross-realm names yet (the reference reads one out of
-- UnitName's second return and appends it to whispers and targets).
dropdownMenu.server = nil;
UnitPopup_HideButtons();
-- Nothing but Cancel left? Then there is no menu worth opening.
local count = 0;
for index, value in ipairs(UnitPopupMenus[which]) do
if ( UnitPopupShown[index] == 1 and value ~= "CANCEL" ) then
count = count + 1;
end
end
if ( count < 1 ) then
return;
end
-- The two nested rows wear the live setting as their own label, and open only for
-- whoever can change it — a member still gets to read them.
local lootMethod = GetLootMethod();
dropdownMenu.selectedLootMethod = UnitLootMethod[lootMethod].text;
dropdownMenu.selectedLootThreshold = getglobal("ITEM_QUALITY" .. GetLootThreshold() .. "_DESC");
local methodRow, thresholdRow = UnitPopupButtons["LOOT_METHOD"], UnitPopupButtons["LOOT_THRESHOLD"];
methodRow.text = dropdownMenu.selectedLootMethod;
thresholdRow.text = dropdownMenu.selectedLootThreshold;
local leads = ((GetNumPartyMembers() > 0) or (GetNumRaidMembers() > 0)) and IsPartyLeader();
methodRow.nested = leads and 1 or nil;
thresholdRow.nested = leads and 1 or nil;
-- A level-2 open is a submenu of the row that owns it: the value the kit hands back
-- names both the menu and the owner. Every row in it is shown — the hide pass ran
-- against the parent menu.
if ( UIDROPDOWNMENU_MENU_LEVEL == 2 ) then
local submenu = UIDROPDOWNMENU_MENU_VALUE;
dropdownMenu.which = submenu;
rememberOpenMenu(UIDROPDOWNMENU_MENU_LEVEL, dropdownMenu);
for index, value in ipairs(UnitPopupMenus[submenu]) do
local info = rowInfo(value);
info.owner = submenu;
-- The live loot method and threshold tick themselves; a mark row ticks when
-- the unit already wears it — a mark's POSITION in the icon menu is its
-- number, which is why the row index is what gets compared.
if ( info.text == dropdownMenu.selectedLootMethod
or info.text == dropdownMenu.selectedLootThreshold ) then
info.checked = 1;
elseif ( isRaidMarkRow(value) and GetRaidTargetIndex(unit) == index ) then
info.checked = 1;
end
UIDropDownMenu_AddButton(info, UIDROPDOWNMENU_MENU_LEVEL);
end
return;
end
-- The unit's name titles the menu.
if ( unit or name ) then
UIDropDownMenu_AddButton({
text = name or TEXT(UNKNOWN),
isTitle = 1,
notCheckable = 1,
});
end
rememberOpenMenu(UIDROPDOWNMENU_MENU_LEVEL, dropdownMenu);
for index, value in ipairs(UnitPopupMenus[which]) do
if ( UnitPopupShown[index] == 1 ) then
local info, row = rowInfo(value);
info.owner = which;
if ( not row.checkable ) then
info.notCheckable = 1;
end
if ( row.nested ) then
info.hasArrow = 1;
end
-- The threshold row wears the live threshold's own quality colour.
if ( value == "LOOT_THRESHOLD" ) then
applyTextColor(info, ITEM_QUALITY_COLORS[GetLootThreshold()]);
end
if ( isRaidMarkRow(value) and GetRaidTargetIndex("target") == index ) then
info.checked = 1;
end
UIDropDownMenu_AddButton(info);
end
end
PlaySound("igMainMenuOpen");
end
function UnitPopup_HideButtons()
local menu = getglobal(UIDROPDOWNMENU_INIT_MENU);
local party = partyState(menu);
for index, value in ipairs(UnitPopupMenus[menu.which]) do
local hide = hidePredicate(value);
-- The DEFERRED set is asked first, so nothing can resurrect one of its rows.
if ( BENILLA_UNITPOPUP_DEFERRED[value] or (hide and hide(menu, party)) ) then
UnitPopupShown[index] = 0;
else
UnitPopupShown[index] = 1;
end
end
end
-- ── Which shown rows are clickable right now ───────────────────────────────────────
-- The enable pass runs every frame over the open menus, so a row that can go grey owns a
-- predicate too: "grey this out?". Range is handled for every row at once, off the row's
-- own interact-distance class.
local GREY_ROW = { };
GREY_ROW["LEAVE"] = function(menu, party)
return party.inParty == 0;
end
-- In a group, only the leader or an assistant may bring someone else in.
GREY_ROW["INVITE"] = function(menu, party)
return party.inParty == 1 and party.isLeader == 0 and party.isAssistant == 0;
end
local function greyUnlessLeading(menu, party)
return party.inParty == 0 or party.isLeader == 0;
end
GREY_ROW["UNINVITE"] = greyUnlessLeading;
GREY_ROW["PROMOTE"] = greyUnlessLeading;
GREY_ROW["WHISPER"] = function(menu)
return menu.unit and not UnitIsConnected(menu.unit);
end
-- Ref UnitPopup.lua l.502-505: greyed while either party is dead or a ghost, or while we
-- lack full control (feared/charmed) — `HasFullControl` is nil-guarded like
-- CheckInteractDistance is below, because benilla models no loss of control yet, so the
-- clause is inert rather than an error. The row also carries dist = 3, so the shared
-- range leg gates it too.
GREY_ROW["DUEL"] = function(menu)
return UnitIsDeadOrGhost("player")
or (HasFullControl and not HasFullControl())
or UnitIsDeadOrGhost(menu.unit);
end
-- Only a leader under master loot can hand the job on, and never to its current holder.
GREY_ROW["LOOT_PROMOTE"] = function(menu, party)
local lootMethod, lootMaster = GetLootMethod();
if ( party.inParty == 0 or party.isLeader == 0 or lootMethod ~= "master" ) then
return true;
end
local master = "player";
if ( lootMaster and lootMaster ~= 0 ) then
master = "party" .. lootMaster;
end
return menu.unit and UnitIsUnit(menu.unit, master);
end
-- Out of interact range first (dist 0 means the row has no range at all), then whatever
-- the row itself says.
local function rowEnabled(menu, key, party)
local row = UnitPopupButtons[key];
if ( row.dist > 0 and CheckInteractDistance ) then
if ( not CheckInteractDistance(menu.unit, row.dist) ) then
return false;
end
end
local grey = GREY_ROW[key];
return not (grey and grey(menu, party));
end
-- Somebody else's dropdown is not ours to enable and disable.
local function ownsOpenMenu()
for _, frame in ipairs(UnitPopupFrames) do
if ( UIDROPDOWNMENU_OPEN_MENU == frame ) then
return true;
end
end
end
-- The per-frame enable/disable pass over every open unit-popup menu (driven by the
-- BenillaUnitPopupDriver below — the ref hooks it off UIParent's OnUpdate).
function UnitPopup_OnUpdate(elapsed)
if ( not DropDownList1:IsVisible() or not ownsOpenMenu() ) then
-- Nothing of ours to scan: put the driver back to sleep until the next
-- UnitPopup_ShowMenu (visible frames are the only ones ticked — tick.rs — so
-- this stops the per-frame pass entirely; decision 1396's class).
BenillaUnitPopupDriver:Hide();
return;
end
local party = partyState();
for level, menu in pairs(OPEN_DROPDOWNMENUS) do
if ( menu ) then
-- The kit numbers buttons by SHOWN position, so only shown rows advance the
-- count; the +1 steps over the title the level-1 menu puts at button 1. (The
-- reference offsets every level alike, level 2's title-less lists included.)
local count = 0;
for index, value in ipairs(UnitPopupMenus[menu.which]) do
if ( UnitPopupShown[index] == 1 ) then
count = count + 1;
if ( rowEnabled(menu, value, party) ) then
UIDropDownMenu_EnableButton(level, count + 1);
else
UIDropDownMenu_DisableButton(level, count + 1);
end
end
end
end
end
end
-- ── What a row does when it is clicked ─────────────────────────────────────────────
-- One handler per row, keyed by the row's own name. A row with no handler does nothing —
-- which is how Cancel, the nested parent rows, and every DEFERRED row are expressed
-- (header: TARGET, RESET_INSTANCES).
local CLICK_ROW = { };
CLICK_ROW["WHISPER"] = function(menu)
ChatFrame_SendTell(menu.name);
end
-- A targeted invite goes by token; a chat name or /who row has only the name.
CLICK_ROW["INVITE"] = function(menu)
if ( menu.unit ) then
InviteToParty(menu.unit);
else
InviteByName(menu.name);
end
end
CLICK_ROW["UNINVITE"] = function(menu)
UninviteFromParty(menu.unit);
end
CLICK_ROW["PROMOTE"] = function(menu)
PromoteToPartyLeader(menu.unit);
end
CLICK_ROW["LEAVE"] = function()
LeaveParty();
end
-- Resetting goes behind a confirm (ref UnitPopup_OnClick l.620-621): the send is
-- irreversible and takes every dungeon we own at once. The dialog is registered with the
-- rest of the instance arc, in RaidFrame.xml.
CLICK_ROW["RESET_INSTANCES"] = function()
StaticPopup_Show("CONFIRM_RESET_INSTANCES");
end
-- Picking a loot method sets it and writes the choice back onto the parent row — list 1,
-- button 2, the fixed place LOOT_METHOD sits in the SELF menu. Master loot is the one
-- method that also names who holds it.
for rowKey, method in pairs(LOOT_METHOD_TOKENS) do
CLICK_ROW[rowKey] = function(menu)
if ( method == "master" ) then
SetLootMethod(method, menu.name);
else
SetLootMethod(method);
end
UIDropDownMenu_SetButtonText(1, 2, UnitPopupButtons[rowKey].text);
UIDropDownMenu_Refresh(menu, nil, 1);
end
end
CLICK_ROW["LOOT_PROMOTE"] = function(menu)
SetLootMethod("master", menu.name);
end
-- The threshold rows start at uncommon, so the clicked row's own id is one below the
-- quality it sets; the parent row (list 1, button 3) takes that quality's colour.
local function chooseLootThreshold(menu, key, clicked)
local quality = clicked:GetID() + 1;
SetLootThreshold(quality);
local color = ITEM_QUALITY_COLORS[quality];
UIDropDownMenu_SetButtonText(1, 3, UnitPopupButtons[key].text, color.r, color.g, color.b);
end
CLICK_ROW["ITEM_QUALITY2_DESC"] = chooseLootThreshold;
CLICK_ROW["ITEM_QUALITY3_DESC"] = chooseLootThreshold;
CLICK_ROW["ITEM_QUALITY4_DESC"] = chooseLootThreshold;
-- Every row of the icon submenu marks the unit; None clears it (mark 0). Registering
-- against that menu's own membership is also what keeps the parent RAID_TARGET_ICON row
-- out of it — it opens the submenu, it does not mark.
local function setRaidMark(menu, key)
local mark = string.sub(key, 13);
if ( mark == "NONE" ) then
mark = 0;
end
SetRaidTargetIcon(menu.unit, tonumber(mark));
end
for _, key in ipairs(UnitPopupMenus["RAID_TARGET_ICON"]) do
CLICK_ROW[key] = setRaidMark;
end
-- Challenge to a duel (decision 0633): the app resolves the unit token → player guid →
-- a cast of the duel spell (ref UnitPopup_OnClick l.559, StartDuelUnit).
CLICK_ROW["DUEL"] = function(menu)
StartDuelUnit(menu.unit);
end
-- Offer to trade (decision 0592 P1): the app resolves the unit token → player guid →
-- CMSG_INITIATE_TRADE.
CLICK_ROW["TRADE"] = function(menu)
InitiateTrade(menu.unit);
end
-- Open the inspect window (decision 0631) — InspectUnit gates on CanInspect and sends
-- CMSG_INSPECT.
CLICK_ROW["INSPECT"] = function(menu)
InspectUnit(menu.unit);
end
-- Begin auto-following, by NAME and EXACT. That `1` is the resolver's exact-only flag,
-- and it matters: this menu already knows the unit's name to the letter, so unlike
-- `/follow rag` it must not prefix-match its way onto a bystander. Client-side movement
-- only, no wire.
CLICK_ROW["FOLLOW"] = function(menu)
FollowByName(menu.name, 1);
end
-- The four PET rows (decision 1066). The asymmetry is the reference's and it is the right
-- one: DISMISS runs immediately, ABANDON goes behind a confirm. Sending a summon away
-- costs nothing; giving up a tamed pet is permanent, and the server deletes it
-- (`PET_SAVE_AS_DELETED`).
CLICK_ROW["PET_DISMISS"] = function()
PetDismiss();
end
CLICK_ROW["PET_ABANDON"] = function()
StaticPopup_Show("ABANDON_PET");
end
CLICK_ROW["PET_PAPERDOLL"] = function()
ToggleCharacter("PetPaperDollFrame");
end
CLICK_ROW["PET_RENAME"] = function()
StaticPopup_Show("RENAME_PET");
end
-- The two GUILD arms, ref UnitPopup_OnClick l.571-575 (decision 1257). Both go behind a
-- confirm, and both are addressed by NAME — a guild roster row has no unit token behind
-- it, which is the reason this menu takes a name at all.
--
-- GUILD_PROMOTE stashes the name on the DIALOG (`dialog.data`), not on a global: the
-- popup engine hands `data` back to OnAccept, so the dialog acts on the name that was
-- right-clicked even if the selection moved while it was open. GUILD_LEAVE needs no
-- data — it is only ever offered on yourself — and formats the guild's own name into
-- "Really leave %s?". The `if dialog` is ours, and it is not decoration: the ref writes
-- `dialog.data` on the bare return because its engine runs FOUR dialog instances and
-- ours runs two (UiPanels.xml's header), so a refused Show returning nil is reachable
-- here and indexing it would raise inside a menu click.
CLICK_ROW["GUILD_PROMOTE"] = function(menu)
local dialog = StaticPopup_Show("CONFIRM_GUILD_PROMOTE", menu.name);
if ( dialog ) then
dialog.data = menu.name;
end
end
CLICK_ROW["GUILD_LEAVE"] = function()
StaticPopup_Show("CONFIRM_GUILD_LEAVE", GetGuildInfo("player"));
end
-- The four raid rank verbs (ref UnitPopup_OnClick l.624-631). Three go by NAME and one by
-- the row index — the reference's own split, and it is the wire's: `CMSG_GROUP_UNINVITE`
-- would take either, but the assistant and leader opcodes need a guid the engine resolves
-- from a name, and the menu never had one to hand for a raid row.
CLICK_ROW["RAID_LEADER"] = function(menu)
PromoteByName(menu.name);
end
CLICK_ROW["RAID_PROMOTE"] = function(menu)
PromoteToAssistant(menu.name);
end
CLICK_ROW["RAID_DEMOTE"] = function(menu)
DemoteAssistant(menu.name);
end
CLICK_ROW["RAID_REMOVE"] = function(menu)
UninviteFromRaid(menu.userData);
end
function UnitPopup_OnClick()
local clicked = this;
local key = clicked.value;
local menu = getglobal(UIDROPDOWNMENU_INIT_MENU);
local click = CLICK_ROW[key];
if ( click ) then
click(menu, key, clicked);
end
PlaySound("UChatScrollButton");
end
]]></Script>
<!-- The OnUpdate driver (ref UIParent.xml l.21 runs UnitPopup_OnUpdate from UIParent's own
OnUpdate; ours has none — the DeathFrame hidden-driver pattern instead). Awake only while
a unit menu of ours is open: UnitPopup_ShowMenu shows it, its own early-out hides it, and
a hidden frame is never ticked (tick.rs). It ships shown so the first tick runs the
early-out once and parks it. -->
<Frame name="BenillaUnitPopupDriver" parent="UIParent" hidden="false">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnUpdate>UnitPopup_OnUpdate(arg1)</OnUpdate>
</Scripts>
</Frame>
</Ui>
@@ -1,953 +0,0 @@
<!--
The fullscreen world map — our implementation of the behaviour the 1.12 FrameXML
WorldMapFrame.xml/.lua specifies (decision 0203 phase 2; decision 0221 settled the layout on
the fullscreen sheet — the windowed / 1.14-header direction of 0214/0217/0219 is parked, and
this faithful fullscreen sheet is the baseline we return to). The reference is the SPEC, not
the source of this text: the names are its contract (globals addons call, frame and template
names, art paths, every constant and texcoord) and the code that uses them is ours. The map
is pure FrameXML over the worldmap host API (benilla-ui script/worldmap.rs): detail tiles are
plain textures whose art the Lua swaps per selection (GetMapInfo →
Interface\WorldMap\<name>\<name>1..12), the player blip is positioned from
GetPlayerMapPosition in the same 0..1 map space the reference uses, navigation is
SetMapZoom/-ToCurrentZone/ProcessMapClick.
IN (this file): the FULLSCREEN frame + blackout, the 12-piece parchment chrome, the title,
the 1002×668 detail frame with its 12 tiles + ADD highlight, the discovery-overlay pool
(0222 — the explored sub-areas paint their detail over the base parchment), the click/hover
button layer, the continent/zone dropdowns (over UIDropDownMenu.xml's kit), the zoom-out
button (panel-button shape — our engine ships no UIPanelButtonTemplate), the close X (the
QuestLog/BagFrame hand-drawn shape), the area label pair, and the player arrow (MinimapArrow
art spun by SetRotation(GetPlayerFacing()) — the reference renders an engine arrow model;
0203 flags the stand-in).
OUT (later slices, per 0203): the ping child; the magnifying-glass zoom-out shortcut (redundant
with the button); SetupFullscreenScale (the map renders at its native 1024×768 centered; a scale
pass is a taste knob).
The raid pool moved half-way out of that list and the halves are worth separating. The 40
`WorldMapRaid` frames are now DECLARED, as inherits= instances of WorldMapRaidUnitTemplate
alongside the four party ones, because the published names and their `$parentIcon` children are
what addons index — pfUI's mapcolors walks all 44 and died on the first missing icon. What is
still OUT is POSITIONING them: `GetPlayerMapPosition` answers (0,0) for every `raid` token, and
the reference's second arm (team members in a battleground) needs GetNumBattlefieldPositions and
GetBattlefieldPosition, neither of which we bind. So they exist, hidden, and WorldMapParty_Update
drives the party arm alone.
WorldMapTooltip was in this OUT list — "only unit blips used it" — and is now declared at the
foot of the file. That reason was about the reference's own consumers and never about the
published NAME, which six corpus addons index directly and pfUI dies on at file scope.
Deviations from the reference, deliberate: BlackoutWorld fills via anchors instead of the
GetScreenWidth resize dance (same pixels); OnShow calls WorldMapFrame_Update() directly after
SetMapToCurrentZone (the reference repaints off the synchronous WORLD_MAP_UPDATE; ours fires
next tick — the direct call keeps the open paint flicker-free); ESC/M close via the app key
arm, not OnKeyDown+RunBinding (no binding system).
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
NUM_WORLDMAP_DETAIL_TILES = 12;
NUM_WORLDMAP_OVERLAYS = 0;
NUM_WORLDMAP_POIS = 0;
-- ref WorldMapFrame.lua l.509-515 is the spec. Through the PANEL MANAGER, not a bare
-- Show/Hide: the map is registered `area = "full"` (UiPanels.xml), which is what makes it
-- take the screen from the other windows on the way in and — the reason this changed —
-- what stops it opening from behind the game menu, the one window that still could
-- (director-reported, decision 0674).
function ToggleWorldMap()
if ( WorldMapFrame:IsVisible() ) then
HideUIPanel(WorldMapFrame);
else
ShowUIPanel(WorldMapFrame);
end
end
-- Fill the screen: scale the native 1024×768 sheet up until it touches the constraining
-- screen edge — full height on any screen wider than 4:3 (the common case), full width on
-- a narrower one (ref SetupFullscreenScale, deferred at 0203, wired at 0224). The blackout
-- is a SEPARATE unscaled frame behind the sheet, so only the sheet grows.
function SetupFullscreenScale()
local xScale = GetScreenWidth() / 1024;
local yScale = GetScreenHeight() / 768;
local scale = xScale;
if ( yScale < xScale ) then
scale = yScale;
end
WorldMapFrame:SetScale(scale);
end
-- One overlay tile's extent along one axis: how many pixels of art it draws, and how wide
-- the FILE holding them is. Every tile is a full 256 except the last of a row or column,
-- which carries whatever remains of the overlay — and its file is padded up to the next
-- power of two (16 the smallest the art uses), so that tile shows only the leading
-- pixels/file fraction of it. Both axes want the same answer, so they ask the same way.
local function overlayTileExtent(index, count, span)
if ( index < count ) then
return 256, 256;
end
local pixels = mod(span, 256);
if ( pixels == 0 ) then
pixels = 256;
end
local file = 16;
while ( file < pixels ) do
file = file * 2;
end
return pixels, file;
end
-- The repaint (spec: ref WorldMapFrame.lua WorldMapFrame_Update): the tile swap, the
-- discovery-overlay pool, and the zoom-out arming (POI landmarks are still deferred). The
-- "World" fallback is the reference's own hack for the nil GetMapInfo at the world level,
-- kept because our host answers nil at that level for the same reason.
function WorldMapFrame_Update()
local mapFileName = GetMapInfo();
if ( not mapFileName ) then
mapFileName = "World";
end
for i=1, NUM_WORLDMAP_DETAIL_TILES, 1 do
getglobal("WorldMapDetailTile"..i):SetTexture("Interface\\WorldMap\\"..mapFileName.."\\"..mapFileName..i);
end
-- The discovery overlays (spec: ref WorldMapFrame.lua, "Setup the overlays"): the base
-- tiles are the unexplored parchment, and each REVEALED sub-area (GetNumMapOverlays /
-- GetMapOverlayInfo, filtered host-side by the explored bitset) paints its detailed
-- art over them. That art is cut into a grid of 256-px tiles, numbered 1..n across
-- then down, which we lay onto ARTWORK textures pooled on WorldMapDetailFrame — the
-- pool grows on demand, never shrinks, and the tail left over from a busier map is
-- parked hidden below. Offsets are native pixels: the map renders at its native
-- 1024×768 (no WORLDMAP_SX/SY scale, unlike the parked windowed layout), so the
-- overlays share the detail tiles' coordinate space untouched.
local used = 0;
for overlay = 1, GetNumMapOverlays() do
local art, artWidth, artHeight, originX, originY = GetMapOverlayInfo(overlay);
local columns = ceil(artWidth/256);
local rows = ceil(artHeight/256);
local wanted = used + (columns * rows);
if ( wanted > NUM_WORLDMAP_OVERLAYS ) then
for slot = NUM_WORLDMAP_OVERLAYS + 1, wanted do
WorldMapDetailFrame:CreateTexture("WorldMapOverlay"..slot, "ARTWORK");
end
NUM_WORLDMAP_OVERLAYS = wanted;
end
-- Tile n of the overlay is file <art>n, and its place in the grid follows from n.
for tile = 1, columns * rows do
local column = mod(tile - 1, columns) + 1;
local row = ceil(tile / columns);
local wide, fileWide = overlayTileExtent(column, columns, artWidth);
local tall, fileTall = overlayTileExtent(row, rows, artHeight);
used = used + 1;
local texture = getglobal("WorldMapOverlay"..used);
texture:SetWidth(wide);
texture:SetHeight(tall);
texture:SetTexCoord(0, wide/fileWide, 0, tall/fileTall);
texture:ClearAllPoints();
texture:SetPoint("TOPLEFT", originX + (256 * (column - 1)), -(originY + (256 * (row - 1))));
texture:SetTexture(art..tile);
texture:Show();
end
end
for slot = used + 1, NUM_WORLDMAP_OVERLAYS do
getglobal("WorldMapOverlay"..slot):Hide();
end
-- The POI icons (spec: ref WorldMapFrame.lua's landmark pass). One frame per landmark
-- the host reports for the displayed map, grown on demand from WorldMapPOITemplate and
-- never shrunk — the tail from a busier map is parked hidden, like the overlay pool
-- above. The host reports the AreaPOI.dbc rows the displayed level admits (town and
-- city icons, a capital's "Under Attack" marker, the Eastern Plaguelands towers),
-- then the guard-directions marker (SMSG_GOSSIP_POI) last.
local numPOIs = GetNumMapLandmarks();
if ( numPOIs > NUM_WORLDMAP_POIS ) then
for slot = NUM_WORLDMAP_POIS + 1, numPOIs do
CreateFrame("Button", "WorldMapFramePOI"..slot, WorldMapButton, "WorldMapPOITemplate");
end
NUM_WORLDMAP_POIS = numPOIs;
end
for slot = 1, NUM_WORLDMAP_POIS do
local poi = getglobal("WorldMapFramePOI"..slot);
if ( slot > numPOIs ) then
poi:Hide();
else
local name, description, textureIndex, x, y = GetMapLandmarkInfo(slot);
WorldMapPOI_SetTexture(poi, textureIndex);
poi.name = name;
poi.description = description;
poi:ClearAllPoints();
poi:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT",
x * WorldMapDetailFrame:GetWidth(), -y * WorldMapDetailFrame:GetHeight());
poi:Show();
end
end
if ( GetCurrentMapContinent() == 0 ) then
WorldMapZoomOutButton:Disable();
else
WorldMapZoomOutButton:Enable();
end
end
-- Point a POI frame's texture at its cell of Interface\Minimap\POIIcons — an 8x8 grid of
-- icons indexed across then down, the same atlas and the same indexing the minimap's
-- landmark blips use (the corpse marker below hardcodes its own cell of it).
function WorldMapPOI_SetTexture(poi, textureIndex)
local column = mod(textureIndex, 8);
local row = floor(textureIndex / 8);
getglobal(poi:GetName().."Texture"):SetTexCoord(
column * 0.125, (column + 1) * 0.125, row * 0.125, (row + 1) * 0.125);
end
-- Every marker on the sheet — the corpse, a POI — names itself in the shared GameTooltip,
-- and every one of them wants the plate opening AWAY from the edge it sits nearest, so the
-- side pick lives here once instead of in each OnEnter. `subtitle` is the second line the
-- landmarks that carry a live status use; the rest pass nothing.
-- The party blips (ref WorldMapFrame.lua:391-403's party arm, report B320): the same
-- (0,0)-means-hide law as the player and the corpse, one frame per slot.
--
-- It rides the per-frame pass beside those two rather than WorldMapFrame_Update, where the
-- reference keeps it: a member's position moves continuously and the map has no event that
-- fires when it does, so placing it on the repaint alone would freeze every blip between
-- roster changes. Its own function so the live pass and the tests enter it identically.
--
-- The reference's RAID arm is not taken — see the blip frames' note.
function WorldMapParty_Update()
for i = 1, 4 do
local blip = getglobal("WorldMapParty" .. i)
local px, py = GetPlayerMapPosition("party" .. i)
if ( px == 0 and py == 0 ) then
blip:Hide()
else
px = px * WorldMapDetailFrame:GetWidth()
py = -py * WorldMapDetailFrame:GetHeight()
blip:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT", px, py)
blip:Show()
end
end
end
-- Ref WorldMapUnit_OnEnter (WorldMapFrame.lua l.457-499) transcribed. Three things it does
-- that the one-line version it replaces did not: it takes NO argument and reads `this` (the
-- reference's calling convention, and now ours, because the name is the reference's and an
-- addon may call it); it drives WorldMapTooltip, which is what the blip templates' OnLeave
-- hides; and it stacks the name of EVERY blip under the cursor rather than one, because
-- blips overlap whenever a group stands together.
--
-- No `or UNKNOWN` fallback, deliberately: a blip is only reached here when it is VISIBLE,
-- and it is only visible when GetPlayerMapPosition answered real coordinates for its unit,
-- so UnitName cannot be nil without a contract already being broken. A guard there would
-- turn that into a silently wrong tooltip instead of a raise (1203).
--
-- `unitButton.name` is the battleground-team label the reference sets for someone on your
-- team but in neither your party nor your raid; our positioning pass never sets it (no
-- GetBattlefieldPosition binding), so that branch is dead here and kept because it is the
-- reference's own fallback order.
function WorldMapUnit_OnEnter()
local x = this:GetCenter()
local parentX = this:GetParent():GetCenter()
if ( x > parentX ) then
WorldMapTooltip:SetOwner(this, "ANCHOR_LEFT")
else
WorldMapTooltip:SetOwner(this, "ANCHOR_RIGHT")
end
local unitButton
local newLineString = ""
local tooltipText = ""
if ( MouseIsOver(WorldMapPlayer) ) then
tooltipText = UnitName(WorldMapPlayer.unit)
newLineString = "\n"
end
for i = 1, MAX_PARTY_MEMBERS do
unitButton = getglobal("WorldMapParty" .. i)
if ( unitButton:IsVisible() and MouseIsOver(unitButton) ) then
tooltipText = tooltipText .. newLineString .. UnitName(unitButton.unit)
newLineString = "\n"
end
end
for i = 1, MAX_RAID_MEMBERS do
unitButton = getglobal("WorldMapRaid" .. i)
if ( unitButton:IsVisible() and MouseIsOver(unitButton) ) then
if ( unitButton.name ) then
tooltipText = tooltipText .. newLineString .. unitButton.name
else
tooltipText = tooltipText .. newLineString .. UnitName(unitButton.unit)
end
newLineString = "\n"
end
end
WorldMapTooltip:SetText(tooltipText)
WorldMapTooltip:Show()
end
function WorldMapMarker_ShowTooltip(marker, title, subtitle)
local markerX = marker:GetCenter();
local sheetX = marker:GetParent():GetCenter();
if ( markerX > sheetX ) then
GameTooltip:SetOwner(marker, "ANCHOR_LEFT");
else
GameTooltip:SetOwner(marker, "ANCHOR_RIGHT");
end
GameTooltip:SetText(title);
if ( subtitle and subtitle ~= "" ) then
GameTooltip:AddLine(subtitle, 1, 1, 1);
end
GameTooltip:Show();
end
-- The reference's rule: zone view → its continent; continent view → the world.
function WorldMapZoomOutButton_OnClick()
if ( GetCurrentMapZone() ~= 0 ) then
SetMapZoom(GetCurrentMapContinent());
else
SetMapZoom(0);
end
end
-- ── The continent/zone dropdowns (the reference's wiring; its `arg.n` vararg walk is
-- written here as select("#", ...), the 5.1-native spelling and this tree's house style
-- for it). ────────────────────────────────────────────────────────────────────────────
function WorldMapContinentsDropDown_OnLoad()
UIDropDownMenu_Initialize(this, WorldMapContinentsDropDown_Initialize);
UIDropDownMenu_SetWidth(130);
end
function WorldMapContinentsDropDown_Initialize()
WorldMapFrame_LoadContinents(GetMapContinents());
end
function WorldMapFrame_LoadContinents(...)
local info;
for i=1, select("#", ...), 1 do
info = {};
info.text = select(i, ...);
info.func = WorldMapContinentButton_OnClick;
UIDropDownMenu_AddButton(info);
end
end
function WorldMapZoneDropDown_OnLoad()
UIDropDownMenu_Initialize(this, WorldMapZoneDropDown_Initialize);
UIDropDownMenu_SetWidth(130);
end
function WorldMapZoneDropDown_Initialize()
WorldMapFrame_LoadZones(GetMapZones(GetCurrentMapContinent()));
end
function WorldMapFrame_LoadZones(...)
local info;
for i=1, select("#", ...), 1 do
info = {};
info.text = select(i, ...);
info.func = WorldMapZoneButton_OnClick;
UIDropDownMenu_AddButton(info);
end
end
function WorldMapContinentButton_OnClick()
UIDropDownMenu_SetSelectedID(WorldMapContinentDropDown, this:GetID());
SetMapZoom(this:GetID());
end
function WorldMapZoneButton_OnClick()
UIDropDownMenu_SetSelectedID(WorldMapZoneDropDown, this:GetID());
SetMapZoom(GetCurrentMapContinent(), this:GetID());
end
function WorldMap_UpdateZoneDropDownText()
if ( GetCurrentMapZone() == 0 ) then
UIDropDownMenu_ClearAll(WorldMapZoneDropDown);
else
UIDropDownMenu_SetSelectedID(WorldMapZoneDropDown, GetCurrentMapZone());
end
end
function WorldMap_UpdateContinentDropDownText()
if ( GetCurrentMapContinent() == 0 ) then
UIDropDownMenu_ClearAll(WorldMapContinentDropDown);
else
UIDropDownMenu_SetSelectedID(WorldMapContinentDropDown, GetCurrentMapContinent());
end
end
-- Where the cursor sits inside a map-sized frame, as a fraction of it measured from the
-- TOPLEFT: (0,0) the top-left corner of the map art, (1,1) the bottom-right. Both sides of
-- the host's map API — the click (ProcessMapClick) and the hover (UpdateMapHighlight) —
-- take a position in exactly that space, and the cursor arrives in screen pixels with the
-- y axis pointing the other way, so this is the one conversion between them: undo the
-- frame's effective scale, then measure from its edges, which a frame gives up as its
-- center and its size (each edge being half the size away from the center).
local function cursorMapPosition(frame)
local cursorX, cursorY = GetCursorPosition();
local scale = frame:GetEffectiveScale();
local midX, midY = frame:GetCenter();
local frameWidth = frame:GetWidth();
local frameHeight = frame:GetHeight();
local fromLeft = (cursorX / scale) - (midX - (frameWidth/2));
local fromTop = (midY + (frameHeight/2)) - (cursorY / scale);
return fromLeft / frameWidth, fromTop / frameHeight;
end
-- Left click drills into whatever the host finds at that map position; every other button
-- is the zoom-out shortcut. ref WorldMapFrame.lua l.277-296. The SECOND argument is the button the click landed on,
-- defaulting to `this` — restored in 1793's sweep, having been dropped in favour of naming
-- `WorldMapButton` directly.
--
-- Equivalent for 1.12's own caller, which passes exactly that button
-- (`WorldMapFrame.lua:174`) — and not equivalent for anyone else, which is the whole
-- reason the reference takes it: a caller aiming the click at a different map surface was
-- silently redirected to the main one, because Lua discards a surplus argument without
-- complaint.
function WorldMapButton_OnClick(mouseButton, button)
CloseDropDownMenus();
if ( mouseButton == "LeftButton" ) then
if ( not button ) then
button = this
end
ProcessMapClick(cursorMapPosition(button));
else
WorldMapZoomOutButton_OnClick();
end
end
-- The per-frame hover pass (spec: ref WorldMapButton_OnUpdate), reduced to the area
-- highlight + the player blip (raid/flags/ping are still deferred; the party blips
-- landed with B320 and ride WorldMapFrame_Update, not this pass).
-- UpdateMapHighlight is a stub until the area-bitmap source lands (wow-re dispatch) — a
-- nil area/art takes the hide paths.
function WorldMapButton_OnUpdate(elapsed)
local width = this:GetWidth();
local height = this:GetHeight();
local area, art, cropX, cropY, sizeX, sizeY, atX, atY =
UpdateMapHighlight(cursorMapPosition(this));
WorldMapFrameAreaLabel:SetText(area or "");
if ( art ) then
-- The sub-area under the cursor lights up: <zone>Highlight holds one shape per
-- sub-area, of which crop names the fraction actually used (the file is padded to
-- a power of two), while size and at are the shape's extent and top-left corner as
-- fractions of the map — so both scale by the button's pixel size. Nothing to draw
-- for a degenerate shape, but the art is still swapped in for the next frame.
WorldMapHighlight:SetTexCoord(0, cropX, 0, cropY);
WorldMapHighlight:SetTexture("Interface\\WorldMap\\"..art.."\\"..art.."Highlight");
local drawWidth = sizeX * width;
local drawHeight = sizeY * height;
if ( drawWidth > 0 and drawHeight > 0 ) then
WorldMapHighlight:SetWidth(drawWidth);
WorldMapHighlight:SetHeight(drawHeight);
WorldMapHighlight:SetPoint("TOPLEFT", "WorldMapDetailFrame", "TOPLEFT", atX * width, -atY * height);
WorldMapHighlight:Show();
end
else
WorldMapHighlight:Hide();
end
-- The player blip: the reference's engine arrow, as our rotating texture stand-in.
WorldMapPlayerIcon:SetRotation(GetPlayerFacing());
local playerX, playerY = GetPlayerMapPosition("player");
if ( playerX == 0 and playerY == 0 ) then
WorldMapPlayer:Hide();
else
playerX = playerX * WorldMapDetailFrame:GetWidth();
playerY = -playerY * WorldMapDetailFrame:GetHeight();
WorldMapPlayer:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT", playerX, playerY);
WorldMapPlayer:Show();
end
WorldMapParty_Update()
-- Position corpse (ref WorldMapFrame.lua:443-452 — the law is that (0,0) means hide).
local corpseX, corpseY = GetCorpseMapPosition();
if ( corpseX == 0 and corpseY == 0 ) then
WorldMapCorpse:Hide();
else
corpseX = corpseX * WorldMapDetailFrame:GetWidth();
corpseY = -corpseY * WorldMapDetailFrame:GetHeight();
WorldMapCorpse:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT", corpseX, corpseY);
WorldMapCorpse:Show();
end
end
]]></Script>
<!-- The fullscreen blackout — a separate, UNSCALED frame behind the map sheet (0224). The map
itself (WorldMapFrame) is now the native 1024×768 sheet that SetupFullscreenScale grows to
fill the screen; the blackout stays screen-sized so the world never shows through the
margins, and eats clicks so nothing leaks to the world behind it. -->
<!-- BlackoutWorld — **the reference's name**, which this frame did not carry until now.
The reference declares it as a `<Texture>` in WorldMapFrame's own BACKGROUND layer
(WorldMapFrame.xml l.8: 1024x768, BOTTOMLEFT, solid black); ours is a separate
mouse-enabled fullscreen Frame, which is the deliberate deviation the header records —
it blacks the world out AND eats the clicks the reference's fullscreen map frame would
have taken. That structural choice stands.
What was wrong was the NAME. `WorldMapBlackout` was ours; `BlackoutWorld` is the published
global, and four corpus addons index it directly — pfUI calls `BlackoutWorld:Hide()` at
`modules/map.lua:109` and died there. This file's own header has been calling it
"BlackoutWorld" in prose the whole time while the code said otherwise.
**The kind still differs and is not papered over**: `GetObjectType()` answers "Frame"
here and "Texture" in the reference. Every corpus caller only Shows/Hides it, which both
kinds answer identically; an addon that asked its type would see the deviation, and that
is the honest cost of the structural choice above.
**And it carries NO `parent=`, for the reason WorldMapFrame does not** (see that frame's
note below). The reference's blackout is a region *inside* WorldMapFrame, so it belongs to
the very panel `SetFullScreenFrame` shows and cannot be reached by the `UIParent:Hide()`
that showing it performs. Ours is a separate frame — the structural deviation above — and
while that frame hung off `UIParent` it inherited the opposite property: opening the map
hid the blackout, and the 3D world came back through the margins beside the 4:3 sheet
(director-reported, decision 1757). Top-level restores the reference's own relationship;
the TOPLEFT/BOTTOMRIGHT pair then pins to the screen root, which is the same rect
`parent="UIParent"` was resolving to. -->
<Frame name="BlackoutWorld" frameStrata="FULLSCREEN" hidden="true" enableMouse="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture setAllPoints="true"><Color r="0.0" g="0.0" b="0.0"/></Texture>
</Layer>
</Layers>
</Frame>
<!-- One POI icon on the map (spec: ref WorldMapPOITemplate, WorldMapFrameTemplates.xml): a
16x16 cell of Interface\Minimap\POIIcons picked by the landmark's own textureIndex, with a
name/description tooltip. Grown on demand by WorldMapFrame_Update into WorldMapButton. -->
<Button name="WorldMapPOITemplate" virtual="true" enableMouse="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentTexture" file="Interface\Minimap\POIIcons" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>
WorldMapMarker_ShowTooltip(this, this.name, this.description);
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Button>
<!--
**No `parent=`, deliberately, and the reference is the same.** `WorldMapFrame` is an
`area = "full"` panel, so showing it runs `SetFullScreenFrame` — which hides `UIParent`.
A world map parented to `UIParent` is therefore hidden by the very call that shows it:
`Show()` sets its own shown flag and the cascade keeps `IsVisible()` false.
benilla carried `parent="UIParent"` here (the reference does not) and got away with it for
as long as `SetFullScreenFrame` had its `UIParent:Hide()` line dropped. Decision 1734
restored that line, and this was the frame it broke. `CinematicFrame` is unparented for
exactly the same reason — every `full` panel must be.
-->
<Frame name="WorldMapFrame" enableKeyboard="true" frameStrata="FULLSCREEN_DIALOG" hidden="true" enableMouse="true">
<Size><AbsDimension x="1024" y="768"/></Size>
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\WorldMap\UI-WorldMap-Top1">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-512" y="384"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Top2">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-256" y="384"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Top3">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Top4">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="256" y="384"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Middle1">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-512" y="128"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Middle2">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-256" y="128"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Middle3">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="0" y="128"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Middle4">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="256" y="128"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Bottom1">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-512" y="-128"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Bottom2">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-256" y="-128"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Bottom3">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-128"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\WorldMap\UI-WorldMap-Bottom4">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="256" y="-128"/></Offset></Anchor></Anchors>
</Texture>
<FontString inherits="GameFontNormal" text="World Map">
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="372"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Frame name="WorldMapPositioningGuide">
<Size><AbsDimension x="1024" y="768"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
<!-- ref: the continent picker (id 1) at the top row's left, the zone picker (id 2)
chained to its right, the zoom-out button closing the row. OnLoad instead of the
ref's OnShow (our engine fires OnShow only on the frame itself, not children of
a shown parent; OnLoad + the WORLD_MAP_UPDATE re-init below cover every open). -->
<Frame name="WorldMapContinentDropDown" inherits="UIDropDownMenuTemplate" id="1">
<Anchors>
<Anchor point="TOP" relativePoint="TOP" relativeTo="WorldMapPositioningGuide">
<Offset><AbsDimension x="-80" y="-35"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString inherits="GameFontNormalSmall" text="Continent">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="20" y="10"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>
WorldMapContinentsDropDown_OnLoad();
</OnLoad>
</Scripts>
</Frame>
<Frame name="WorldMapZoneDropDown" inherits="UIDropDownMenuTemplate" id="2">
<Anchors>
<Anchor point="LEFT" relativeTo="WorldMapContinentDropDown" relativePoint="RIGHT">
<Offset><AbsDimension x="-33" y="0"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString inherits="GameFontNormalSmall" text="Zone">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="20" y="10"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>
WorldMapZoneDropDown_OnLoad();
this:RegisterEvent("WORLD_MAP_UPDATE");
</OnLoad>
<OnEvent>
if ( this:IsVisible() ) then
WorldMapContinentsDropDown_OnLoad();
WorldMap_UpdateContinentDropDownText();
WorldMapZoneDropDown_OnLoad();
WorldMap_UpdateZoneDropDownText();
end
</OnEvent>
</Scripts>
</Frame>
<Button name="WorldMapZoomOutButton" text="Zoom Out">
<Size><AbsDimension x="110" y="22"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="WorldMapZoneDropDown" relativePoint="RIGHT">
<Offset><AbsDimension x="-10" y="3"/></Offset>
</Anchor>
</Anchors>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<Scripts>
<OnClick>WorldMapZoomOutButton_OnClick();</OnClick>
</Scripts>
</Button>
<Button name="WorldMapFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativePoint="TOP" relativeTo="WorldMapPositioningGuide">
<Offset><AbsDimension x="516" y="4"/></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>HideUIPanel(WorldMapFrame);</OnClick>
</Scripts>
</Button>
<Frame name="WorldMapDetailFrame">
<Size><AbsDimension x="1002" y="668"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOP" relativeTo="WorldMapPositioningGuide">
<Offset><AbsDimension x="-502" y="-69"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="WorldMapDetailTile1">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile2">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile1" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile3">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile2" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile4">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile3" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile5">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile1" relativePoint="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile6">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile5" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile7">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile6" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile8">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile7" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile9">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile5" relativePoint="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile10">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile9" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile11">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile10" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="WorldMapDetailTile12">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile11" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<Texture name="WorldMapHighlight" alphaMode="ADD" hidden="true">
<Size><AbsDimension x="128" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer>
</Layers>
</Frame>
<Button name="WorldMapButton" enableMouse="true">
<Size><AbsDimension x="1002" y="668"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailFrame"/></Anchors>
<Frames>
<Frame name="WorldMapPlayer" id="0">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="WorldMapPlayerIcon" file="Interface\Minimap\MinimapArrow" setAllPoints="true"/>
</Layer>
</Layers>
<!-- Ref WorldMapFrame.xml l.473-484. The OnLoad is load-bearing and not
decoration: WorldMapUnit_OnEnter reads `WorldMapPlayer.unit` when the
cursor is over the arrow, and this is the only place it is ever set.
The hover pair is transcribed with it and is LIVE, which is the
opposite of what an earlier draft of this comment claimed. Neither the
reference's frame nor ours carries enableMouse, but a declared OnEnter
AUTO-ENABLES the mouse (wow-re ui/scratch/scripts-auto-enable.md §1,
VERIFIED: a handler reaches the same enable primitive 0x76af00(2,-1)
as the attribute). So the arrow takes the mouse and names the player on
hover, exactly as the reference's does — and frame_flag_gate's KNOWN
entry for this frame, which existed only because we had the flag-gap
without the handler, went stale the moment this landed. -->
<Scripts>
<OnLoad>
this.unit = "player";
this:SetFrameLevel(this:GetFrameLevel() + 1);
</OnLoad>
<OnEnter>WorldMapUnit_OnEnter()</OnEnter>
<OnLeave>WorldMapTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!-- The corpse marker (decision 0308 §5) — ref WorldMapCorpseTemplate
(WorldMapFrameTemplates.xml l.55-88) inlined: 16x16, the POIIcons skull
cell (0.875-1.0 / 0-0.125), hover names it CORPSE_RED. The ref anchors its
tooltip to a WorldMapTooltip; ours rides the shared GameTooltip through
the side-pick every marker on the sheet shares. That parenthetical used to
read "this map ships no separate tooltip" and is no longer true —
WorldMapTooltip is declared below. The UNIT blips moved onto it with
WorldMapUnit_OnEnter; the sheet MARKERS (this one, the POIs) deliberately
did not, because they share WorldMapMarker_ShowTooltip and moving one
moves all of them. That is a deviation left standing, not an oversight. -->
<Frame name="WorldMapCorpse" hidden="true" enableMouse="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\Minimap\POIIcons" setAllPoints="true">
<TexCoords left="0.875" right="1.0" top="0" bottom="0.125"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnEnter>
WorldMapMarker_ShowTooltip(this, "|cffff2020Corpse|r")
</OnEnter>
<OnLeave>
GameTooltip:Hide()
</OnLeave>
</Scripts>
</Frame>
<!-- The group blips — ref WorldMapFrame.xml l.486-529, which declares all 44 of them as
inherits= instances of WorldMapUnitTemplate / WorldMapRaidUnitTemplate. Ours now does the
same; they used to be four hand-written Frames carrying an ANONYMOUS texture, which cost
the `$parentIcon` name every addon recolouring a blip reaches for (see
WorldMapFrameTemplates.xml's header for the pfUI raise that found it).
The RAID forty are declared here and positioned NOWHERE: WorldMapParty_Update below drives
the party arm only, because `GetPlayerMapPosition` answers (0,0) for every `raid` token and
the reference's battleground arm needs GetNumBattlefieldPositions / GetBattlefieldPosition,
which we do not bind. Declared-and-hidden is what the reference does with them out of a
raid anyway; the difference is that ours stay that way. Named in the OUT list. -->
<Frame name="WorldMapParty1" inherits="WorldMapUnitTemplate" id="1"/>
<Frame name="WorldMapParty2" inherits="WorldMapUnitTemplate" id="2"/>
<Frame name="WorldMapParty3" inherits="WorldMapUnitTemplate" id="3"/>
<Frame name="WorldMapParty4" inherits="WorldMapUnitTemplate" id="4"/>
<Frame name="WorldMapRaid1" inherits="WorldMapRaidUnitTemplate" id="1"/>
<Frame name="WorldMapRaid2" inherits="WorldMapRaidUnitTemplate" id="2"/>
<Frame name="WorldMapRaid3" inherits="WorldMapRaidUnitTemplate" id="3"/>
<Frame name="WorldMapRaid4" inherits="WorldMapRaidUnitTemplate" id="4"/>
<Frame name="WorldMapRaid5" inherits="WorldMapRaidUnitTemplate" id="5"/>
<Frame name="WorldMapRaid6" inherits="WorldMapRaidUnitTemplate" id="6"/>
<Frame name="WorldMapRaid7" inherits="WorldMapRaidUnitTemplate" id="7"/>
<Frame name="WorldMapRaid8" inherits="WorldMapRaidUnitTemplate" id="8"/>
<Frame name="WorldMapRaid9" inherits="WorldMapRaidUnitTemplate" id="9"/>
<Frame name="WorldMapRaid10" inherits="WorldMapRaidUnitTemplate" id="10"/>
<Frame name="WorldMapRaid11" inherits="WorldMapRaidUnitTemplate" id="11"/>
<Frame name="WorldMapRaid12" inherits="WorldMapRaidUnitTemplate" id="12"/>
<Frame name="WorldMapRaid13" inherits="WorldMapRaidUnitTemplate" id="13"/>
<Frame name="WorldMapRaid14" inherits="WorldMapRaidUnitTemplate" id="14"/>
<Frame name="WorldMapRaid15" inherits="WorldMapRaidUnitTemplate" id="15"/>
<Frame name="WorldMapRaid16" inherits="WorldMapRaidUnitTemplate" id="16"/>
<Frame name="WorldMapRaid17" inherits="WorldMapRaidUnitTemplate" id="17"/>
<Frame name="WorldMapRaid18" inherits="WorldMapRaidUnitTemplate" id="18"/>
<Frame name="WorldMapRaid19" inherits="WorldMapRaidUnitTemplate" id="19"/>
<Frame name="WorldMapRaid20" inherits="WorldMapRaidUnitTemplate" id="20"/>
<Frame name="WorldMapRaid21" inherits="WorldMapRaidUnitTemplate" id="21"/>
<Frame name="WorldMapRaid22" inherits="WorldMapRaidUnitTemplate" id="22"/>
<Frame name="WorldMapRaid23" inherits="WorldMapRaidUnitTemplate" id="23"/>
<Frame name="WorldMapRaid24" inherits="WorldMapRaidUnitTemplate" id="24"/>
<Frame name="WorldMapRaid25" inherits="WorldMapRaidUnitTemplate" id="25"/>
<Frame name="WorldMapRaid26" inherits="WorldMapRaidUnitTemplate" id="26"/>
<Frame name="WorldMapRaid27" inherits="WorldMapRaidUnitTemplate" id="27"/>
<Frame name="WorldMapRaid28" inherits="WorldMapRaidUnitTemplate" id="28"/>
<Frame name="WorldMapRaid29" inherits="WorldMapRaidUnitTemplate" id="29"/>
<Frame name="WorldMapRaid30" inherits="WorldMapRaidUnitTemplate" id="30"/>
<Frame name="WorldMapRaid31" inherits="WorldMapRaidUnitTemplate" id="31"/>
<Frame name="WorldMapRaid32" inherits="WorldMapRaidUnitTemplate" id="32"/>
<Frame name="WorldMapRaid33" inherits="WorldMapRaidUnitTemplate" id="33"/>
<Frame name="WorldMapRaid34" inherits="WorldMapRaidUnitTemplate" id="34"/>
<Frame name="WorldMapRaid35" inherits="WorldMapRaidUnitTemplate" id="35"/>
<Frame name="WorldMapRaid36" inherits="WorldMapRaidUnitTemplate" id="36"/>
<Frame name="WorldMapRaid37" inherits="WorldMapRaidUnitTemplate" id="37"/>
<Frame name="WorldMapRaid38" inherits="WorldMapRaidUnitTemplate" id="38"/>
<Frame name="WorldMapRaid39" inherits="WorldMapRaidUnitTemplate" id="39"/>
<Frame name="WorldMapRaid40" inherits="WorldMapRaidUnitTemplate" id="40"/>
<Frame name="WorldMapFrameAreaFrame">
<Size><AbsDimension x="400" y="128"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="WorldMapFrameAreaLabel" inherits="WorldMapTextFont">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="20" y="-20"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="WorldMapFrameAreaDescription" inherits="SubZoneTextFont">
<Anchors><Anchor point="TOP" relativeTo="WorldMapFrameAreaLabel" relativePoint="BOTTOM"/></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
</Frames>
<Scripts>
<OnLoad>
this:RegisterForClicks("LeftButtonUp", "RightButtonUp");
</OnLoad>
<OnClick>
WorldMapButton_OnClick(arg1);
</OnClick>
<OnUpdate>
WorldMapButton_OnUpdate(arg1);
</OnUpdate>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>
this:RegisterEvent("WORLD_MAP_UPDATE");
-- The hover area label inherits WorldMapTextFont (nominal 102). 33 was the
-- director's taste call (0235) — which the one-to-one raster cap (the fold-back
-- record; wow-re fontstring-overflow.md) now clamps to the byte law's 32, one
-- unit under that call and closer to it than the 36 the atlas snap used to
-- deliver. The real client draws this SetFont(33) at 32 too. Still a taste dial:
-- any value ≤ 32 is honored exactly.
WorldMapFrameAreaLabel:SetFont("Fonts\\FRIZQT__.TTF", 33);
WorldMapFrame_Update();
</OnLoad>
<!-- UpdateMicroButtons leads both, as in the ref (WorldMapFrame.xml l.599/606) — live
since the micro-menu slice (MicroMenu.xml). -->
<OnShow>
BlackoutWorld:Show();
SetupFullscreenScale();
SetMapToCurrentZone();
WorldMapFrame_Update();
if UpdateMicroButtons then UpdateMicroButtons() end;
PlaySound("igQuestLogOpen");
CloseDropDownMenus();
</OnShow>
<OnHide>
BlackoutWorld:Hide();
if UpdateMicroButtons then UpdateMicroButtons() end;
PlaySound("igQuestLogClose");
</OnHide>
<OnEvent>
if ( WorldMapFrame:IsVisible() ) then
WorldMapFrame_Update();
end
</OnEvent>
</Scripts>
</Frame>
<!-- WorldMapTooltip (ref WorldMapFrame.xml l.636, verbatim attributes).
**This file's header listed it OUT, and the reason has expired.** The note said "only unit
blips used it" — true of the REFERENCE's own consumers, and never the question. The name is
a published global that addons index directly, and six in the corpus do: !Questie,
Cartographer, CT_MapMod, Gatherer, pfQuest and pfUI. Three of those are on the failing
roster, and for pfUI it is a load-time blocker — `modules/map.lua:13` indexes it at file
scope, so the addon dies there whatever the map is drawing.
Declared, not driven: this is the widget under its reference name and geometry, so an
addon that seats its own lines in it works. Our OWN map still routes its hovers through
the shared GameTooltip (the two notes at l.666/l.692 say so and stay true) — adopting the
reference's separate-tooltip routing is a different change, and doing half of it silently
would be worse than either. -->
<GameTooltip name="WorldMapTooltip" frameStrata="TOOLTIP" hidden="true" parent="WorldMapFrame" inherits="GameTooltipTemplate"/>
</Ui>
@@ -1,72 +0,0 @@
<!--
The world map's per-unit blip templates, transcribed from the reference's own
`Interface\FrameXML\WorldMapFrameTemplates.xml` (l.3-35). Two virtual Frames and nothing else:
`WorldMapUnitTemplate` (a party member) and `WorldMapRaidUnitTemplate` (a raid member), which
inherits it and re-states its OnLoad without the `party` token.
**Why they are a file of their own rather than four hand-written copies inside WorldMapFrame.xml.**
They used to be exactly that: `WorldMapParty1..4` written out four times, each carrying an
ANONYMOUS `<Texture>`. Anonymous is the bug. The reference's blip names its icon `$parentIcon`,
and that published name is load-bearing for addons — pfUI's `modules/mapcolors.lua` recolours
party and raid blips by class and reaches them as
frame.icon = _G[frame_name.."Icon"]
frame.icon:SetTexture(pfUI.media["img:circleparty"])
so against four anonymous textures line 11 raised `attempt to index field 'icon' (a nil value)`
and took pfUI's whole module chain down with it. A template gets the naming right once.
**The raid pool is DECLARED but not DRIVEN, and that is the honest half-step.** `mapcolors`
loops `1, MAX_RAID_MEMBERS` (40) as well as `1, MAX_PARTY_MEMBERS` (4), so all 44 frames have to
exist for it to survive; the reference declares all 44 too (WorldMapFrame.xml l.486-529). What
ours cannot do yet is POSITION the raid forty: `GetPlayerMapPosition` resolves `player` and
`party1..4` and answers (0,0) for every `raid` token (worldmap.rs l.487-505), and the
reference's second raid arm — battleground team members — needs `GetNumBattlefieldPositions`
and `GetBattlefieldPosition`, neither of which we bind. So the forty are declared, hidden, and
left where the reference leaves them until a raid actually has coordinates to place them at.
Declaring a frame the reference declares is not stubbing behaviour (1134 §4): the frames and
their art are real, and the missing piece is a data source, named here and in WorldMapFrame.xml's
OUT list rather than faked with an empty setter.
The hover is the reference's `WorldMapUnit_OnEnter`, which reads `this` and owns
`WorldMapTooltip` — both of which now exist, so the template's `WorldMapTooltip:Hide()` OnLeave
has something to hide. Our body is the single-unit case; the reference additionally walks every
overlapping blip with MouseIsOver and stacks their names into one tooltip, which we do not.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!-- No enableMouse, exactly as the reference declares it: the OnEnter/OnLeave below
AUTO-ENABLE the mouse on their own (wow-re ui/scratch/scripts-auto-enable.md §1,
VERIFIED — a script handler reaches the same enable primitive 0x76af00(2,-1) the
attribute does). Adding the attribute would be redundant, and frame_flag_gate reads
the loaded ENGINE rather than our XML precisely so the two spellings cannot drift. -->
<Frame name="WorldMapUnitTemplate" hidden="true" virtual="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>
this:SetFrameLevel(this:GetFrameLevel() + 1);
this.unit = "party" .. this:GetID();
getglobal(this:GetName() .. "Icon"):SetTexture("Interface\\WorldMap\\WorldMapPartyIcon");
</OnLoad>
<OnEnter>WorldMapUnit_OnEnter()</OnEnter>
<OnLeave>WorldMapTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!-- Ref l.29-35 verbatim: the same frame WITHOUT the `party` token, because a raid blip's unit
is assigned by the positioning pass (`partyMemberFrame.unit = unit`, WorldMapFrame.lua
l.386) rather than derived from its id. -->
<Frame name="WorldMapRaidUnitTemplate" inherits="WorldMapUnitTemplate" virtual="true">
<Scripts>
<OnLoad>
this:SetFrameLevel(this:GetFrameLevel() + 1);
getglobal(this:GetName() .. "Icon"):SetTexture("Interface\\WorldMap\\WorldMapPartyIcon");
</OnLoad>
</Scripts>
</Frame>
</Ui>
@@ -1,280 +0,0 @@
<Ui>
<!--
The always-up world-state readout — report B190's second half: the alliance/horde progress
line an outdoor-PvP zone or a battleground shows at the top of the screen. In Eastern
Plaguelands that is the two tower counters ("Towers Controlled: 3"); in Warsong Gulch the
flag captures; in Arathi Basin bases and resources.
The SPEC is the reference's two C bindings, byte-carved (wow-re
system/ui/scratch/worldstate-ui-law.md, 0x4c5a40 / 0x4c5a70) and re-expressed here:
GetNumWorldStateUI() -> how many rows the current scope admits
GetWorldStateUIInfo(i) -> TEN values, in this order:
uiState, text, icon, dynamicIcon,
tooltip, dynamicTooltip, extendedUI,
extUIState1, extUIState2, extUIState3
Note what that return is NOT: there is no uiType and no hidden in 1.12 — those are a later
expansion of the API, and reading this list as if they were there shifts every value by two.
`text` arrives already macro-expanded (the host runs the reference's %<id>w expander);
every other string is its DBC column verbatim, and never nil — an empty column is "".
Which rows exist at all is the host's (crates/benilla-app/src/world_state_ui.rs), including
the gate that makes this frame look broken if you don't know about it: the world-PvP rows
(Eastern Plaguelands, Silithus) are Type 1, which the reference admits only while the player
has joined the zone-dependent DEFENSE chat channel. Leave LocalDefense and the readout goes
away while the map icons stay.
THE GEOMETRY IS THE REFERENCE'S, and it is not what you would guess (wow-re
worldstate-ui-law.md §12, carved 2026-08-25; decision 1604 folds it back). The first pass
of this frame invented a snug 20x20 icon in a 22px row, and that was wrong in a way that
showed: EVERY icon these rows name is a sprite authored into the UPPER-LEFT corner of a
power-of-two canvas with the rest transparent — AllianceTower/HordeTower are a 16x16 sprite
at (3,2) of a 32x32 file, UI-PVP-Alliance/Horde a ~40x40 crest in a 64x64 one. Squeeze that
whole canvas into a snug box and the visible art floats up and to the left of the box,
away from its label. NOTHING IS CROPPED to fix that — there is no <TexCoords> on any of
these regions, and a texture with none samples the full 0..1 quad (CSimpleTexture's ctor
0x76fc40 stamps it). The compensation is geometric, and these three numbers are one tuned
system: the icon slot is OVERSIZED to 42x42 (a 32px file upscaled 1.3125x), hung SIX UNITS
OFF the row's left edge, and the text is seated TEN UNITS ABOVE the slot's centreline. The
upper-left ink then lands as a ~21x21 square whose centre sits just under the text's, which
is what "icon next to its label" is supposed to look like.
THERE ARE TWO ICON SLOTS, fed by two different DBC columns, and shipped content never puts
the same art in both:
Icon (+0x0c) -> the 42x42 slot below. AllianceTower, HordeTower,
UI-PVP-Alliance, UI-PVP-Horde.
DynamicIcon (+0x64) -> a SEPARATE 32x32 button off the row's right edge, shown only
while uiState == 2. HordeFlag/AllianceFlag, Warsong Gulch only.
So the dynamic icon does NOT replace the static one — both are on screen together, which is
the Warsong Gulch look: the faction shield with its capture count, and a flashing enemy
flag to its right while a carrier is running. `uiState == 2` is the flag-taken value; the
first pass swapped the art on `uiState ~= 0`, which fired on the ordinary state 1 and
replaced a shield that should never have moved.
OUT, named: the CAPTUREPOINT extended UI — the sliding bar over
Interface\WorldStateFrame\WorldState-CaptureBar that the one Eastern Plaguelands progress
row drives. Its two states come through the binding already (extUIState1/2 carry the
alliance/horde fractions), so it is a widget to add, not a seam to change. NOW KNOWN, and
worth writing down before someone builds it: in the reference that row never reaches this
frame at all — the update path branches on a non-empty extendedUI FIRST, and id 138 is the
only always-up-class row with one, so EPL's real readout is two text rows here plus a
separate bar anchored under the MinimapCluster. Until that bar exists we keep showing the
row as its text line (decision 1590 §7's stand-in, unchanged) so the progress number is on
screen; it costs a third line the reference doesn't draw, and it goes when the bar lands.
Also out: the battleground scoreboard (the Type 2 rows), which the reference builds from the
same DBC through a DIFFERENT handler and a different pair of bindings whose bail leg is not
even the same shape — GetNumBattlefieldStats/GetBattlefieldStatInfo, a separate slice.
-->
<Script>
<![CDATA[
NUM_ALWAYS_UP_ROWS = 0;
-- The reference's own numbers (worldstate-ui-law.md §12). The row is an anchor node, not a
-- box: its 42-wide icon slot hangs off its left edge and its text runs past its right, so
-- none of these are "the readout's size" — they are the offsets that seat corner-authored
-- art against a label. The pitch is the template height, because rows chain TOP to the
-- previous row's BOTTOM with no gap.
WORLD_STATE_ROW_HEIGHT = 24;
-- The dynamic-icon flash: 0.5 s in, 0.5 s out, indefinitely.
WORLD_STATE_FLASH_HALF = 0.5;
-- The flash overlay's ramp. The art (…Flash.blp) is DXT1 with no alpha channel — black IS
-- the transparency — which is why it draws ADD; the pulse is the region's own alpha.
function WorldStateDynamicIcon_OnUpdate(elapsed)
local t = (this.flashTime or 0) + elapsed;
local period = 2 * WORLD_STATE_FLASH_HALF;
while ( t >= period ) do
t = t - period;
end
this.flashTime = t;
local level = t / WORLD_STATE_FLASH_HALF;
if ( level > 1 ) then
level = 2 - level;
end
getglobal(this:GetName() .. "Flash"):SetAlpha(level);
end
-- Rebuild the readout from the host's list. Cheap enough to run whole on every fire: the
-- list is at most a handful of rows, and the events are a zone's worth apart.
function WorldStateAlwaysUpFrame_Update()
local numUI = GetNumWorldStateUI();
-- Grow the row pool on demand and never shrink it, the way every pooled list here
-- does: the tail from a busier zone is parked hidden rather than destroyed. Row 1
-- hangs off the container; every later row chains to the one above it, so the pitch is
-- the template's height and lives in exactly one place.
if ( numUI > NUM_ALWAYS_UP_ROWS ) then
for i = NUM_ALWAYS_UP_ROWS + 1, numUI do
local row = CreateFrame("Frame", "WorldStateAlwaysUpFrame" .. i,
WorldStateAlwaysUpFrame, "WorldStateAlwaysUpTemplate");
if ( i == 1 ) then
row:SetPoint("TOP", WorldStateAlwaysUpFrame, "TOP", -23, -20);
else
row:SetPoint("TOP", getglobal("WorldStateAlwaysUpFrame" .. (i - 1)),
"BOTTOM", 0, 0);
end
end
NUM_ALWAYS_UP_ROWS = numUI;
end
for i = 1, NUM_ALWAYS_UP_ROWS do
local row = getglobal("WorldStateAlwaysUpFrame" .. i);
if ( i > numUI ) then
row:Hide();
else
local uiState, text, icon, dynamicIcon, tooltip, dynamicTooltip = GetWorldStateUIInfo(i);
local name = row:GetName();
-- The static slot takes its column verbatim. An EMPTY column is not a special
-- case: SetTexture("") clears the art and draws nothing while the authored
-- 42x42 rect — and so everything anchored to it — stays exactly where it was.
-- The slot is reserved and blank, never collapsed; a row that slid its text
-- left into the empty slot would ragged the readout's left edge, and the
-- reference does no such thing.
getglobal(name .. "Icon"):SetTexture(icon);
getglobal(name .. "Text"):SetText(text);
row.tooltip = tooltip;
-- The dynamic slot is a SEPARATE button off the row's right edge, and it is
-- the flag-taken state alone that shows it.
local dynamic = getglobal(name .. "DynamicIcon");
if ( dynamicIcon ~= "" and uiState == 2 ) then
getglobal(dynamic:GetName() .. "Texture"):SetTexture(dynamicIcon);
getglobal(dynamic:GetName() .. "Flash"):SetTexture(dynamicIcon .. "Flash");
dynamic.tooltip = dynamicTooltip;
dynamic.flashTime = 0;
dynamic:Show();
else
dynamic:Hide();
end
row:Show();
end
end
-- OURS, and non-visual: the reference's container is a bare 10x10 anchor node it never
-- resizes, so its own bottom says nothing about how far the rows reach. We keep the
-- authored size but grow the height to the rows' real extent, so "how much of the top
-- of the screen is this using?" is a question the frame can answer — the dev cost pill
-- asks it, and seats itself below (crates/benilla-app/src/perf/hud.rs). Rows anchor to
-- the container's TOP, which is fixed, so nothing moves.
if ( numUI > 0 ) then
WorldStateAlwaysUpFrame:SetHeight(20 + numUI * WORLD_STATE_ROW_HEIGHT);
WorldStateAlwaysUpFrame:Show();
else
WorldStateAlwaysUpFrame:Hide();
end
end
function WorldStateAlwaysUpFrame_OnEvent()
WorldStateAlwaysUpFrame_Update();
end
]]>
</Script>
<!-- One row: the oversized icon slot, its line of text, and the dynamic-icon button that only
Warsong Gulch ever lights. Grown on demand into WorldStateAlwaysUpFrame by the update
above. Every offset here is the reference's — see the header for why they are not the
snug numbers you would pick by eye. -->
<Frame name="WorldStateAlwaysUpTemplate" virtual="true" enableMouse="true">
<Size><AbsDimension x="45" y="24"/></Size>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentIcon">
<Size><AbsDimension x="42" y="42"/></Size>
<Anchors>
<Anchor point="LEFT">
<Offset><AbsDimension x="-6" y="0"/></Offset>
</Anchor>
</Anchors>
</Texture>
<FontString name="$parentText" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors>
<Anchor point="LEFT" relativeTo="$parentIcon" relativePoint="RIGHT">
<Offset><AbsDimension x="-12" y="10"/></Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentDynamicIcon" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="RIGHT">
<Offset><AbsDimension x="40" y="9"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentTexture">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<Texture name="$parentFlash" alphaMode="ADD">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnUpdate>
WorldStateDynamicIcon_OnUpdate(arg1);
</OnUpdate>
<OnEnter>
if ( this.tooltip and this.tooltip ~= "" ) then
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
GameTooltip:SetText(this.tooltip);
GameTooltip:Show();
end
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnEnter>
if ( this.tooltip and this.tooltip ~= "" ) then
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
GameTooltip:SetText(this.tooltip);
GameTooltip:Show();
end
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Frame>
<!-- The container. Top-centre of the screen, which is where an outdoor-PvP zone's counters and
a battleground's score belong: the minimap owns the top-right and the readout must not
fight it. The reference declares it 10x10 and never touches it again — it is an anchor
node, and rows overflow it freely (see the update for the one thing we do differently, and
why). Hidden until a scope admits a row. -->
<Frame name="WorldStateAlwaysUpFrame" parent="UIParent" toplevel="true" hidden="true">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="UIParent" relativePoint="TOP">
<Offset><AbsDimension x="-5" y="-15"/></Offset>
</Anchor>
</Anchors>
<Scripts>
<OnLoad>
-- UPDATE_WORLD_STATES is the reference's own event for this list (0x20e). The
-- entering-world fire is what paints the readout on a login that lands inside a
-- scope the server has already sent states for.
this:RegisterEvent("UPDATE_WORLD_STATES");
this:RegisterEvent("PLAYER_ENTERING_WORLD");
</OnLoad>
<OnEvent>
WorldStateAlwaysUpFrame_OnEvent();
</OnEvent>
</Scripts>
</Frame>
</Ui>
File diff suppressed because it is too large Load Diff
+158 -7
View File
@@ -2,7 +2,9 @@
//!
//! ```text
//! cargo run -q -p benilla-app --example addon_harness -- <folder> [--verbose] [--why <substr>] [--deep [n]] [--status <file>] [--diff <file>]
//! or: ... -- <folder> --probe <Name> [--eval <lua>]... (one addon, then ask its VM)
//! or: ... -- <folder> --probe <Name> [--eval <lua> | --mouse <x>,<y> | --tick <secs>]... (one addon, then
//! ask its VM — the steps run in the order given, so a read can be taken with the cursor
//! parked somewhere the addon cares about)
//! ```
//!
//! The instrument decision 1188 phase 6 asks for: *"which addons work" is a number that can be
@@ -304,6 +306,96 @@ fn main() {
let _ = DEEP.set(deep);
let root = std::path::PathBuf::from(root);
// `--together` — the whole folder in ONE VM, the control for the survey's one-VM-per-addon
// bound. Not a column and not a headline (see `addon_harness::together`'s header for what it
// cannot answer); `--diff <a survey roster>` names the rows the bound is costing, which is
// the question it exists for.
if rest.iter().any(|a| a == "--together") {
let rows = addon_harness::together::survey_together(&root);
if rows.is_empty() {
eprintln!(
"no addons under {} — is that an AddOns folder?",
root.display()
);
std::process::exit(1);
}
let raised: Vec<&addon_harness::together::TogetherRow> =
rows.iter().filter(|r| r.always_raises()).collect();
let wobbly: Vec<&addon_harness::together::TogetherRow> =
rows.iter().filter(|r| r.order_sensitive()).collect();
println!(
"\n{} addon(s) under {}, ALL IN ONE VM — {} runs",
rows.len(),
root.display(),
addon_harness::together::DEFAULT_RUNS
);
println!(
" raised in EVERY run : {}/{} (clean in every run: {})",
raised.len(),
rows.len(),
rows.len() - raised.len() - wobbly.len()
);
// Named, never folded into either count: Lua hashes a table key by its pointer, so a
// registry keyed by objects walks in a different order every process (see the module
// doc). A row that raises in some runs and not others is that showing through.
println!(
" ORDER-SENSITIVE — raised in some runs, not all ({}):",
wobbly.len()
);
for r in &wobbly {
println!(
" {:<28} {}/{} {}",
r.name,
r.raised_in,
r.runs,
r.errors[0].lines().next().unwrap_or("")
);
}
if let Some(path) = &diff {
// The survey's roster, read back: `ok` there and clean here is agreement; `fail`
// there and clean here is the bound, priced.
let prior: std::collections::BTreeMap<String, bool> = std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter_map(|l| l.rsplit_once(' '))
.map(|(n, v)| (n.trim().to_string(), v.trim() == "ok"))
.collect();
let mut freed: Vec<&str> = Vec::new();
let mut only_together: Vec<&str> = Vec::new();
for r in &rows {
// Only the rows that are the same in every run are compared — an order-sensitive
// one belongs to neither list, which is the whole point of naming it separately.
match (prior.get(&r.name), r.raised_in) {
(Some(false), 0) => freed.push(&r.name),
(Some(true), n) if n == r.runs => only_together.push(&r.name),
_ => {}
}
}
println!(
"\n FAILS ALONE, CLEAN TOGETHER ({}) — the one-VM bound, priced:",
freed.len()
);
for n in &freed {
println!(" {n}");
}
println!(
"\n CLEAN ALONE, RAISES TOGETHER ({}) — a neighbour's global, or an order the\n \x20 survey never reaches:",
only_together.len()
);
for n in &only_together {
println!(" {n}");
}
}
println!(
"\n STILL RAISING IN EVERY RUN, WITH EVERY NEIGHBOUR PRESENT (first error each):"
);
for r in &raised {
let first = r.errors[0].lines().next().unwrap_or("");
println!(" {:<28} {first}", r.name);
}
return;
}
// `--probe <Name> [--eval <lua> ...]` — ONE addon, loaded the way the survey loads it, then
// asked. Handled before the survey because it is not one: it prints no column and it is not a
// measurement (an eval can mutate the VM), so mixing the two outputs would invite a probe
@@ -314,13 +406,28 @@ fn main() {
.position(|a| a == "--probe")
.and_then(|i| rest.get(i + 1))
{
let evals: Vec<String> = rest
// `--eval <lua>` and `--mouse <x>,<y>` are ONE ordered list, not two: a read taken
// before the cursor arrived and one taken after answer different questions, and which is
// which is the order the caller typed.
let steps: Vec<addon_harness::probe::Step> = rest
.iter()
.enumerate()
.filter(|(_, a)| *a == "--eval")
.filter_map(|(i, _)| rest.get(i + 1).cloned())
.filter_map(|(i, a)| match a.as_str() {
"--eval" => Some(addon_harness::probe::Step::Eval(rest.get(i + 1)?.clone())),
"--tick" => Some(addon_harness::probe::Step::Tick(
rest.get(i + 1)?.parse().ok()?,
)),
"--mouse" => {
let (x, y) = rest.get(i + 1)?.split_once(',')?;
Some(addon_harness::probe::Step::Mouse(
x.trim().parse().ok()?,
y.trim().parse().ok()?,
))
}
_ => None,
})
.collect();
let Some(out) = addon_harness::probe::probe(&root, name, &evals) else {
let Some(out) = addon_harness::probe::probe(&root, name, &steps) else {
eprintln!(
"no manifest under {}/{name} — is that an addon folder?",
root.display()
@@ -331,7 +438,7 @@ fn main() {
report_lines("load errors", &out.load_errors);
report_lines("session errors", &out.session_errors);
if out.answers.is_empty() {
println!(" (no --eval given — load and session errors only)");
println!(" (no --eval/--mouse given — load and session errors only)");
}
for (chunk, answer) in &out.answers {
println!("\n {chunk}");
@@ -364,7 +471,7 @@ fn main() {
// globals are missing, and every number below is worse for a reason that has nothing to do
// with the client — say so rather than letting two machines' numbers be compared in silence.
println!(
" VM: our whole FrameXML + a seated session{}\n",
" VM: the stock 1.12 FrameXML off the player's chain + a seated session{}\n",
if addon_harness::seated_with_global_strings() {
" + the real GlobalStrings.lua"
} else {
@@ -383,6 +490,18 @@ fn main() {
" loaded without a single load error : {loaded}/{}",
reports.len()
);
// **The reconciliation line, printed always** (decision 2155). `loaded` counts what RAISED;
// a manifest entry naming a file the package does not contain is not that — the reference logs
// `Couldn't open %s` and carries on — and it used to be counted here. Every past record's
// figure was the stricter one, so the stricter one is printed beside the honest one rather
// than left for a reader to reconstruct: two numbers cannot be silently confused, one can.
let strict = reports.iter().filter(|r| r.errors.is_empty()).count();
println!(
" (…{} of those name a file their own package does not contain, which the reference \
logs and carries on from; the pre-2155 column counted those as failures: {strict}/{})",
loaded - strict,
reports.len()
);
println!(
" ...and calling nothing we lack : {clean}/{}",
reports.len()
@@ -565,6 +684,38 @@ fn main() {
}
}
// **What was WARNED about, ranked** — the channel that reached nobody until 2135. It is
// printed after the error rankings and before the distribution on purpose: these are not
// blockers (nothing raised, the addon is running), so they must not outrank a row somebody is
// stuck on — but they are the only column that can see an addon quietly getting the wrong
// thing, which is the class `render` was added for and reaches later.
//
// Ranked by how many ADDONS hit each row, not by total occurrences: a warning one addon fires
// in an OnUpdate would otherwise bury one that fifty addons hit once.
let mut warned: std::collections::BTreeMap<String, usize> = Default::default();
for r in &reports {
let mut seen: std::collections::BTreeSet<String> = Default::default();
for w in &r.warnings {
seen.insert(addon_harness::normalise(w));
}
for w in seen {
*warned.entry(w).or_default() += 1;
}
}
if !warned.is_empty() {
let mut rows: Vec<(String, usize)> = warned.into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let addons = reports.iter().filter(|r| !r.warnings.is_empty()).count();
println!(
"\n what was WARNED about ({addons} addons raised at least one, by addon count):"
);
// Through `ranked`, not a bare `take(12)`: this list is a queue like every other one here,
// and a silent cut reads as "that is the whole list" (1242's rule, which this block was
// written outside of). It cost a measurement — asking the corpus how many `SetPoint`
// targets fail to resolve, the answer sat below the cut and the column read zero.
ranked(rows, 12);
}
// The distribution, because a mean would hide the shape.
let mut buckets = [0usize; 5];
for r in &reports {
File diff suppressed because it is too large Load Diff
+73 -18
View File
@@ -63,11 +63,34 @@ fn wrapped(chunk: &str) -> String {
)
}
/// Load `name` out of `root` the way the survey does, drive the session start, then evaluate each
/// chunk in `evals` against the VM that is left.
/// One step of a probe run, in the order the caller asked for it.
///
/// A probe used to be a list of chunks alone, which cannot ask the question half of this corpus
/// is about: an addon's map, bar and tooltip behaviour is **hover-driven**, and the state a read
/// finds with the cursor parked off-screen is not the state a player is in. Cartographer's world
/// map is the case that forced this — its area label is hidden at every zone change and shown
/// again only by `WorldMapButton`'s `OnEnter`, so `WorldMapFrameAreaLabel:IsShown()` reads `nil`
/// in a cursor-less VM whether the hover works or not. Interleaving the move with the reads is
/// what tells those two apart.
#[derive(Debug, Clone)]
pub enum Step {
/// Lua to evaluate against the VM as it stands.
Eval(String),
/// Move the cursor to `(x, y)` in UI units (y-up from the bottom-left), firing the real
/// `OnLeave`/`OnEnter` pair exactly as [`UiScript::mouse_move`] does for the app.
Mouse(f32, f32),
/// Advance the engine one frame of `secs` — the `OnUpdate` pump. An addon that hooks a
/// FrameXML `OnUpdate` (Cartographer secure-hooks `WorldMapButton_OnUpdate`) raises there and
/// nowhere else, so a probe that never ticks reports it clean. The answer is what the tick
/// raised, or `no errors`.
Tick(f32),
}
/// Load `name` out of `root` the way the survey does, drive the session start, then run each
/// [`Step`] against the VM that is left.
///
/// `None` when the folder has no manifest — the same refusal [`super::survey`] makes by filtering.
pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome> {
pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
let toc_path = super::manifest_path(root, name)?;
// Decoded, not `read_to_string`'d, for the reason `survey_one` states: a cp1252 manifest read
// as UTF-8 parses as an EMPTY toc, and an addon with no files reads as a clean pass.
@@ -89,15 +112,17 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome>
};
script.set_instruction_budget(super::ADDON_INSTRUCTION_BUDGET);
script.set_screen_size(1024.0, 768.0);
// `None` roots: a probe must never read or write the director's real saved variables, for the
// same reason the survey does not call `finish_ui_load` (1213 §4).
script.register_addons(registry, None, None, None);
// `None` **saved-variable** roots: a probe must never read or write the director's real ones,
// for the same reason the survey does not call `finish_ui_load` (1213 §4). The AddOns root is
// passed, exactly as [`super::survey_one`] passes it — a probe VM that answers `MISSING` to
// every `LoadAddOn` is not the VM the row came from (decision 2102).
script.register_addons(registry, Some(root.to_path_buf()), None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
let mut dep_order: Vec<String> = Vec::new();
let mut dep_order: Vec<super::LoadedDep> = Vec::new();
super::load_dependencies(
&script,
&mut script,
root,
&toc,
&installed,
@@ -105,18 +130,48 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome>
&mut dep_order,
);
let (load_errors, _absent) = super::load_addon_files(&script, root, name, &toc);
let session_errors = super::drive_session_start(&mut script, name, &dep_order);
let load_errors = super::load_addon_files(&script, root, name, &toc).errors;
// Same stamp the survey applies, for the same reason: the registry must agree with the VM
// about what is loaded, or `IsAddOnLoaded` and every dependency verdict answer for a session
// that does not exist. The chain stamped itself as it loaded (2166); this is the surveyed one.
script.mark_addon_loaded(name);
let session_errors = super::drive_session_start(&mut script, name, &installed);
let answers = evals
let answers = steps
.iter()
.map(|chunk| {
let answer = script
.eval::<String>(&wrapped(chunk))
// A raise HERE is the wrapper failing to compile the caller's chunk — a syntax
// error in what they typed — not the chunk raising, which `pcall` already caught.
.unwrap_or_else(|e| format!("SYNTAX: {e}"));
(chunk.clone(), answer)
.map(|step| match step {
Step::Eval(chunk) => {
let answer = script
.eval::<String>(&wrapped(chunk))
// A raise HERE is the wrapper failing to compile the caller's chunk — a syntax
// error in what they typed — not the chunk raising, which `pcall` already
// caught.
.unwrap_or_else(|e| format!("SYNTAX: {e}"));
(chunk.clone(), answer)
}
Step::Tick(secs) => {
let before = script.errors().len();
script.tick(*secs);
let raised: Vec<String> = script.errors().into_iter().skip(before).collect();
(
format!("--tick {secs}"),
if raised.is_empty() {
"= no errors".to_string()
} else {
format!("= RAISED: {}", raised.join(" | "))
},
)
}
Step::Mouse(x, y) => {
// `resolve` first: the hit-test reads resolved rects, and a frame the addon
// created or moved since the last resolve has none (`pointer`'s module doc).
script.resolve();
script.mouse_move(*x, *y);
let focus = script
.hit_test_name(*x, *y)
.unwrap_or_else(|| "<nothing>".into());
(format!("--mouse {x},{y}"), format!("= over {focus}"))
}
})
.collect();
@@ -6,7 +6,7 @@
use std::path::{Path, PathBuf};
use super::probe::probe;
use super::probe::{probe, Step};
/// One throwaway AddOns root, cleaned up on drop even if a test panics.
struct Fixtures(PathBuf);
@@ -71,10 +71,10 @@ fn the_probe_reads_the_vm_after_the_session_start() {
fx.root(),
"Talker",
&[
"return TalkerFileScope".to_string(),
"return TalkerAtLogin".to_string(),
"return nil + 1".to_string(),
"return TalkerFileScope".to_string(),
Step::Eval("return TalkerFileScope".to_string()),
Step::Eval("return TalkerAtLogin".to_string()),
Step::Eval("return nil + 1".to_string()),
Step::Eval("return TalkerFileScope".to_string()),
],
)
.expect("the fixture has a manifest");
@@ -115,9 +115,9 @@ fn the_probe_sees_the_whole_folder_installed() {
fx.root(),
"Asker",
&[
"return AskerSaw".to_string(),
"return GetNumAddOns()".to_string(),
"return SiblingRan".to_string(),
Step::Eval("return AskerSaw".to_string()),
Step::Eval("return GetNumAddOns()".to_string()),
Step::Eval("return SiblingRan".to_string()),
],
)
.expect("the fixture has a manifest");
@@ -0,0 +1,324 @@
//! **The control for the one-VM-per-addon bound** — the whole folder in ONE VM, the way a real
//! client runs it.
//!
//! The survey next door loads each addon into a VM of its own, with only its declared dependencies
//! underneath, and says so in its own header: *the headline is a floor, not an estimate*. That
//! bound is real and it is the largest single category left in the corpus's failures — a package
//! that does not ship `Tablet-2.0` and simply expects the library to be there, because in a folder
//! of 219 addons seventy of its neighbours ship a copy and Ace2's registry is one global table.
//!
//! **A bound that is stated and never measured is an excuse.** This is the measurement: one Lua
//! state, every addon in the folder walked in load order, dependencies first, each addon's
//! `ADDON_LOADED` at its own position — then the session start, once, for everybody. What comes
//! back is *which addons raise inside their own files when their neighbours are present*, and the
//! difference between that list and the survey's is exactly the size of the bound.
//!
//! ## What this is NOT
//!
//! Not a replacement for the survey and not a headline. It cannot attribute a *render* or a *use*
//! result (every addon's frames are in one world), it cannot tell "clean" from "never reached",
//! and one addon's runaway is everybody's. Attribution here is by the RAISING CHUNK and nothing
//! else — the same rule the survey applies to session errors, and the only one available when 219
//! addons share a state.
//!
//! ## It is run N times, and the reason is the whole finding
//!
//! **A big shared VM is not reproducible, and that is a property of Lua rather than a defect of
//! ours.** Lua 5.1 hashes a **table key by its POINTER** (`hashpointer`), so `pairs()` over a
//! registry keyed by objects — which is what every Ace2 library's registry is — walks in a
//! different order on every process, because ASLR moves the addresses. Measured directly rather
//! than deduced: twelve fresh tables as keys in one table, iterated, gave `3,7,11,1,…` then
//! `12,5,9,2,…` then `11,1,4,8,…` on three consecutive runs of the same binary.
//!
//! The reference's Lua does exactly the same thing, so this is a fact about the sessions we are
//! modelling and not something to fix. What it means for an INSTRUMENT is that a single run's
//! number cannot be quoted: three consecutive runs here disagreed by one addon and swapped three
//! rows. So the walk runs [`DEFAULT_RUNS`] times in fresh VMs and the verdict is per row:
//!
//! - **raised in EVERY run** — a real failure with the neighbours present;
//! - **raised in SOME** — order-sensitive, named as such and never folded into either count;
//! - **clean in every run** — clean.
//!
//! The per-addon survey next door has been stable run to run (three rosters, byte-identical), and
//! the reason is scale rather than luck: one addon and its declared dependencies rarely build a
//! pointer-keyed registry big enough for the order to change an outcome. "Rarely" is not "never",
//! which is worth knowing about every `--diff` this arc has read as exact.
//!
//! **LoadOnDemand is ignored on purpose.** 62 of the corpus's manifests carry `## LoadOnDemand: 1`
//! and the boot walk skips every one (`0x51f600` loads only records whose LoadOnDemand byte is 0),
//! but the corpus's own addons then demand-load them: `FuBar.lua:1034`'s `LoadLoadOnDemandPlugins`
//! pulls in every installed `FuBar_*` the moment any one of them seats, and Auctioneer's stub does
//! the same for its family. A run that honoured the flag would answer for a session five minutes
//! shorter than the one anybody plays.
use std::collections::BTreeSet;
use std::path::Path;
use benilla_ui::script::UiScript;
use super::{
corpus, load_addon_files, load_dependencies, manifest_path, LoadedDep, ADDON_INSTRUCTION_BUDGET,
};
use benilla_ui::toc::Toc;
/// How many times [`survey_together`] repeats the walk before it reports — see the module doc on
/// why one run is not quotable. Three is the smallest number that can tell "always" from
/// "sometimes"; each run is about twenty-five seconds.
pub const DEFAULT_RUNS: usize = 3;
/// One addon's verdict in the shared VM.
pub struct TogetherRow {
pub name: String,
/// Raises whose first stack frame is inside **this** addon's own folder, load-time and
/// session-time together — the shared VM cannot separate the two windows per addon. From the
/// FIRST run that produced any, so the text is a real traceback and not a merge of several.
pub errors: Vec<String>,
/// How many of the [`runs`](Self::runs) raised at all.
pub raised_in: usize,
pub runs: usize,
}
impl TogetherRow {
/// Raised in every run — a failure the neighbours do not fix.
pub fn always_raises(&self) -> bool {
self.raised_in == self.runs
}
/// Raised in some runs and not others — the pointer-hash order showing through (module doc).
pub fn order_sensitive(&self) -> bool {
self.raised_in > 0 && self.raised_in < self.runs
}
}
/// Load every addon under `root` into one VM and drive the session start — [`DEFAULT_RUNS`] times,
/// in a fresh VM each time, because one run's answer is not reproducible (module doc).
pub fn survey_together(root: &Path) -> Vec<TogetherRow> {
let mut merged: Vec<TogetherRow> = Vec::new();
for run in 0..DEFAULT_RUNS {
let rows = survey_together_once(root);
if run == 0 {
merged = rows
.into_iter()
.map(|(name, errors)| TogetherRow {
name,
raised_in: usize::from(!errors.is_empty()),
errors,
runs: DEFAULT_RUNS,
})
.collect();
continue;
}
for (name, errors) in rows {
let Some(row) = merged.iter_mut().find(|r| r.name == name) else {
continue;
};
if errors.is_empty() {
continue;
}
row.raised_in += 1;
if row.errors.is_empty() {
row.errors = errors;
}
}
}
merged
}
/// One walk, one VM — the measurement [`survey_together`] repeats.
fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
let (names, installed, registry) = corpus(root);
let Ok(mut script) = UiScript::new() else {
return Vec::new();
};
script.set_screen_size(1024.0, 768.0);
script.register_addons(registry, Some(root.to_path_buf()), None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
// `seen` spans the WHOLE walk, not one addon: a library folder declared by eighty dependents
// loads once, which is the property that makes this a session rather than eighty of them.
let mut seen: BTreeSet<String> = BTreeSet::new();
// `(folder, its own load failures)` — kept beside the walk rather than read back out of the
// VM, because the diagnostics log caps at 256 distinct rows and evicts, and 219 addons
// overflow it. The survey has the same rule for the same reason.
let mut load_errors: Vec<(String, Vec<String>)> = Vec::new();
for name in &names {
let Some(toc) = manifest_path(root, name)
.and_then(|p| std::fs::read(p).ok())
.map(|b| Toc::parse(&benilla_ui::source::decode(&b)))
else {
continue;
};
if !seen.insert(name.to_ascii_lowercase()) {
continue; // already pulled in as somebody's dependency
}
// Re-armed PER ADDON, exactly as the live walk re-arms it (1306): without the reset one
// runaway spends the whole allowance and every addon after it fails for somebody else's
// loop.
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
let mut pulled: Vec<LoadedDep> = Vec::new();
load_dependencies(&mut script, root, &toc, &installed, &mut seen, &mut pulled);
// **A dependency's load failures are collected HERE and nowhere else.** In this VM a
// library folder loads exactly once, under whichever dependent reached it first, so it
// never gets a turn of its own in the loop below — and its failures would simply vanish.
for dep in pulled {
load_errors.push((dep.name, dep.files.errors));
}
let files = load_addon_files(&script, root, name, &toc);
load_errors.push((name.clone(), files.errors));
script.mark_addon_loaded(name);
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.clone())],
);
}
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
script.fire_event(event, Vec::new());
}
for _ in 0..10 {
script.tick(0.1);
}
// Attribution by the raising chunk. `\<Folder>\` rather than a prefix match, because Lua
// truncates a long chunk name from the LEFT (`...Ons\FuBar_DakSmak\Libs\…`) and the prefix is
// the half it eats.
let mut rows: Vec<(String, Vec<String>)> =
names.iter().map(|n| (n.clone(), Vec::new())).collect();
// The LOAD half first, already attributed by whose manifest was being walked — the survey's
// own `loaded` rule applies unchanged: a named file the package does not contain is not a
// raise (2155), so it is not counted here either.
for (folder, errs) in load_errors {
let Some(row) = rows.iter_mut().find(|r| r.0 == folder) else {
continue;
};
row.1
.extend(errs.into_iter().filter(|e| !is_absent_file(e)));
}
for err in script.errors() {
let Some(first) = err.lines().next().map(str::to_ascii_lowercase) else {
continue;
};
// Longest folder name first, so `FuBar` never claims a raise inside `FuBar_AtlasFu`.
let mut owner: Option<usize> = None;
for (i, row) in rows.iter().enumerate() {
let folder = row.0.to_ascii_lowercase();
let hit = first.contains(&format!("\\{folder}\\"))
|| first.contains(&format!("{folder}/"))
|| first.starts_with(&format!("{folder}\\"));
if hit && owner.is_none_or(|o| rows[o].0.len() < row.0.len()) {
owner = Some(i);
}
}
if let Some(i) = owner {
rows[i].1.push(err);
}
}
rows
}
/// A load failure that is only *a file the package does not contain* — the reference logs
/// `Couldn't open %s` and carries on (2155), so it is not a raise here either.
fn is_absent_file(err: &str) -> bool {
err.ends_with(": not found")
|| err.contains("no provider hit for")
|| err.contains("; the whole document it names is missing")
}
#[cfg(test)]
mod tests {
use super::*;
/// **The control answers the question it exists for**: an addon whose package is short a
/// library its neighbour ships fails ALONE and is clean TOGETHER.
///
/// Both halves are asserted, because either one alone would pass for the wrong reason — a
/// control that finds everything clean is worthless, and so is one that finds nothing.
#[test]
fn a_library_a_neighbour_ships_is_there_in_the_shared_vm() {
let tmp =
std::env::temp_dir().join(format!("benilla-harness-together-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let write = |name: &str, toc: &str, file: &str, body: &str| {
let dir = tmp.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
std::fs::write(dir.join(file), body).unwrap();
};
// Loads FIRST (`A` before `Z` under the walk's NTFS collation) and puts the library in
// the one global state, exactly as any of the seventy corpus packages that ship a copy of
// `Tablet-2.0` does.
write(
"AlphaShipsIt",
"## Interface: 11200\nlib.lua\n",
"lib.lua",
"SharedLibrary = { greet = function() return 1 end }\n",
);
// Declares no dependency on it and does not ship it — the corpus's own shape.
write(
"ZuluWantsIt",
"## Interface: 11200\nuse.lua\n",
"use.lua",
"ZuluSaw = SharedLibrary.greet()\n",
);
let alone = crate::addon_harness::survey(&tmp);
let zulu = alone.iter().find(|r| r.name == "ZuluWantsIt").unwrap();
assert!(
!zulu.loaded,
"ALONE it must fail — otherwise this control is measuring nothing: {:?}",
zulu.errors
);
let rows = survey_together(&tmp);
let of = |n: &str| rows.iter().find(|r| r.name == n).unwrap();
assert_eq!(
of("ZuluWantsIt").raised_in,
0,
"TOGETHER the neighbour's library is simply there, in every run: {:?}",
of("ZuluWantsIt").errors
);
assert_eq!(
of("AlphaShipsIt").raised_in,
0,
"and the provider is unaffected: {:?}",
of("AlphaShipsIt").errors
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// ...and a raise is still ATTRIBUTED, rather than the shared VM turning everything green.
#[test]
fn a_raise_in_the_shared_vm_lands_on_the_folder_that_raised() {
let tmp = std::env::temp_dir().join(format!(
"benilla-harness-together-attrib-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&tmp);
for (name, body) in [("Quiet", "QuietGlobal = 1\n"), ("Loud", "error('boom')\n")] {
let dir = tmp.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{name}.toc")),
"## Interface: 11200\nf.lua\n",
)
.unwrap();
std::fs::write(dir.join("f.lua"), body).unwrap();
}
let rows = survey_together(&tmp);
let of = |n: &str| rows.iter().find(|r| r.name == n).unwrap();
assert!(
of("Loud").always_raises() && of("Loud").errors.iter().any(|e| e.contains("boom")),
"the raise is the loud one's, in every run: {:?}",
of("Loud").errors
);
assert_eq!(
of("Quiet").raised_in,
0,
"and not its neighbour's: {:?}",
of("Quiet").errors
);
let _ = std::fs::remove_dir_all(&tmp);
}
}
+11
View File
@@ -36,6 +36,17 @@
//! **query B** (the hit group's own row, exact key, `0x69d8f0`) re-populates the subzone
//! ("Main Hall"). No name-set retry, no cross-row fallback anywhere.
//!
//! **`0x494780` has one more effect than the election, and it does NOT live here** (2192): when
//! the cached zone id was 0 — the first world-enter of a session — it also calls `0x4a6650`, the
//! player→(continent, zone) resolver, and hands the answer to the world map's `SetMap` setter, so
//! a freshly-logged-in client is already on the player's own zone map before any Lua asks. That
//! half is modelled where the map data lives, in
//! [`crate::ui_world_map::world_enter_selection`] — the gate there is the same precondition
//! (`0x67e510` never reaches this updater with a zero zone id), read off our own area feed rather
//! than off the cache below. wow-re carved it as
//! `system/ui/scratch/worldmap-selection-autosync.md`, correcting this note's own earlier source,
//! which had dismissed the call as unrelated.
//!
//! The area *authority* stays `terrain_stream::CurrentArea` (decision 0232 — the MCNK `areaId`
//! with the WMO-interior override, now off the same faces-only claim). Host globals are written
//! before the event fires, so a handler's `GetZoneText()` already sees the new state.
+10
View File
@@ -233,6 +233,16 @@ impl AuraNodes {
pub(crate) fn head_anim_rate(&self) -> Option<f32> {
self.rate.first().map(|(_, r)| *r)
}
/// A node list holding one proc-11 rate node — the state a freeze aura leaves on a unit, for
/// the driver's wound-refusal tenant (decision 2063) without running the aura-slot watcher.
#[cfg(test)]
pub(crate) fn with_rate_node_for_tests(spell_id: u32, rate: f32) -> Self {
Self {
rate: vec![(spell_id, rate)],
..Default::default()
}
}
}
/// Publish every rig's body tint to the per-instance channel ([`benilla_world::instance_tint`], decision
+1 -1
View File
@@ -9,7 +9,6 @@ use benilla_formats::{
char_proc_type, CharProc, SpellVisualCatalog, VisualKit, VisualStages, KIT_CHAR_PROCS,
};
use benilla_protocol::messages::ObjectFields;
use bevy::prelude::*;
use super::*;
use crate::creature_anim::SpellVisuals;
@@ -247,6 +246,7 @@ fn app_with_chains() -> App {
app.add_message::<crate::creature_anim::SpellKitFx>();
app.add_message::<crate::creature_anim::SpellKitSound>();
app.add_message::<AuraProc>();
app.add_message::<crate::creature_anim::BaseAnimRecompute>();
// The water-plane twin map the alpha author composes with (empty here — no water in a fixture).
app.init_resource::<benilla_world::model_render::FarSideTwins>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
-803
View File
@@ -1,803 +0,0 @@
//! The **autocast shine** — the flaming rim that runs round an autocasting pet button — drawn
//! natively on the UI quad APPEND lane (decisions 1383, 1386; ledger B282, B228).
//!
//! ## Why native (B282)
//!
//! Until 1383 the shine was ~92 script-layer `Texture` regions per button, `SetPoint`ed every
//! frame from a Lua `OnUpdate`. Moving a region dirties the layout epoch, so the first layout
//! getter later in the same tick forced a real anchor solve — whose cost is the whole-roster
//! preamble + fingerprint walk, NOT the ~92 moved leaves (decision 1350 scopes the *rounds*, not
//! the gate walk) — and the seeds-grown law forced a second full walk before the gate could
//! close. Measured at the SW gates pin on a default roster: **+4.25 ms cpu/frame for ONE
//! autocasting button**, spark-count-independent (23 sparks cost the same as 92), scaling with
//! the whole UI (an addon-laden roster pays multiples — nazriel's ~11 ms, B282). The reference
//! never pays any of this: its shine is a `<Model>` widget playing an M2 *outside* the FrameXML
//! layout tree. This module is that architecture on our substrate: the script layer carries one
//! **token texture** per button ([`SHINE_TOKEN`], shown/hidden on the autocast *edge*), the
//! extract's conversion records where it sits ([`ShineSite`] — the minimap-slot pattern), and
//! [`emit_shine`] appends the spark quads per frame with zero layout traffic: a shine-only frame
//! leaves the script pipeline fully settled.
//!
//! ## What is drawn — the M2's own law
//!
//! The reference's "autocast is on" mark is a `<Model>` playing
//! `Interface\Buttons\UI-AutoCastButton.mdx`; what that model actually is came out of the shipped
//! .m2 with `benilla-extract m2bones`/`m2part` (this block moved whole from `PetActionBar.xml`,
//! which owned the drawing until 1383):
//!
//! - 4 bones, pivoting at the corners of a 0.02 × 0.02 model-unit square, each keyed with the
//! SAME 5-key 2.000 s translation loop walking the square edge by edge (0.5 s per edge), the
//! four phase-shifted by one corner each so they chase one another round it. The tracks are
//! `interp == 1`, LINEAR: constant speed along each edge, no easing into the corners.
//! - 4 particle emitters, one per bone: 300/s, life 1.000 s, Add blend, unlit, texture
//! `Interface\Buttons\GlowStar.blp`, size half-extent 0.005 → 0.0015 → 0.001 over life, colour
//! (0.976,0.875,0.192,1) → (0.996,0.945,0.745,1) → (1,1,1,0). The spline's 4 control points are
//! ALL at the origin — a particle never travels: it is born where the bone is and shrinks away
//! there. The effect is four comet trails running round the button's rim, not a rotating ring.
//!
//! MODEL UNITS → FrameXML units is the widget's own projection, not a calibration (wow-re
//! `system/ui/scratch/modelframe-animation-clock.md`, decision 1321's fold-back): the ortho leg
//! collapses to **1280 × modelScale × layoutScale** FrameXML units per model unit, independent of
//! aspect and resolution; `scale=` on the ref's `<Model>` is SetModelScale, and the model ORIGIN
//! is the frame rect's BOTTOM-LEFT, which is why a square authored 0..0.02 lands across the whole
//! rect instead of in one quadrant.
//!
//! **The `<Model>`'s rect and scale are per-TEMPLATE, and the reference does not repeat itself**
//! (decision 1391) — which is why they ride the [`ShineSite`] and not a constant:
//!
//! - **pet button** — `setAllPoints` on 30×30 at `scale="1.2"`: the path is **30.72 units**,
//! 1.024× the button, overhanging its top and right by 2.4% and clipped there.
//! - **spell book button** — its own `<Size>36×36</Size>` at `CENTER (1,1)` on a 37×37 button, at
//! `scale="1.22"`: the path is **31.232 units** inside a 36-unit rect that itself sits 1.5 units
//! in from the button's bottom-left.
//!
//! **The stars on that path do NOT take the same projection** ([`STAR`], decisions 1386/1390):
//! their half-extent is an EYE-SPACE length added after the transform, so it misses `modelScale`
//! — the square is scaled by 1.2 and the stars standing on it are not — and, unlike the square,
//! it keeps the ortho leg's aspect term: `2 · half · √(W² + H²)` **window px**, free of UI scale
//! and frame scale. 12.80 px at 1024×768; 14.69 at 1280×720. 1386 froze the 4:3 case into a
//! constant, which is why our 16:9 stars were 22 % small until 1390.
//!
//! **"Free of UI scale" is byte-law, not a loose phrase** (wow-re
//! `system/ui/scratch/modelframe-uiscale-law.md`, a §5 cross-check dispatched *because* the
//! phrasing looked too loose to rest on). `uiScale` is an ordinary `SetScale` on the frame named
//! `UIParent` (`0x494550` → `0x76ac10(ecx = [0xb4b44c])`, and `0x494590` "GetUiScale" is literally
//! `fld [[0xb4b44c]+0x7c]`), and it *does* reach both the widget's rect and the model root
//! (`76d1dd fmul [esi+0x7c]`). It cancels anyway: `0x76d240` builds the ortho `m00 = 2/w` **and**
//! the viewport from the SAME rect, so px per eye unit is `√(W² + H²)` whatever `w` is. So:
//!
//! > **the button scales with the dial and the star does not.** At 2560×1440 a newborn star is
//! > **29.37 px at every setting**, against a 37-unit button of 69.4 / 62.4 / 44.4 px at `uiScale`
//! > 1.0 / 0.9 / 0.64 — 42 % / 47 % / **66 %** of the button.
//!
//! And the consequence that actually reads on screen is not that ratio but this: the four emitters
//! sit on a path that DOES shrink with the dial (`31.2 · uiScale` units), so at 0.64 four
//! fixed-size 29 px stars pack into a 37 px square instead of a 59 px one and **overlap into one
//! blob**. Every bit of that is the reference's own behaviour; there is nothing to fix here, and a
//! low-`uiScale` complaint about this widget is a taste call, not a defect.
//!
//! **And the widget is its own scissor** ([`site_quads`], decision 1387): a `<Model>` installs its
//! frame rect as the VIEWPORT for the duration of its draw and restores it after, so the model
//! cannot paint one pixel outside the button. With the path running on the rim and the stars
//! straddling it, that clip is half the silhouette — the reference's flat outer edge with the
//! flames licking inward, against the soft halo an unclipped draw hangs round the whole button.
//!
//! **We draw the authored population, not a sample of it** (decision 1386). `rate × life` is
//! 300 live particles per emitter, 1200 for the model, and that number IS the look: the bone
//! lays one down every 0.205 units of rim while a newborn star is 12.8 units across at 4:3 (more
//! on a widescreen — [`STAR`]), so ~62 of them pile onto any point of the band and ADD saturates
//! it into one continuous flaming rim —
//! unevenly by channel (the ramp is gold, so R and G clip where B does not, which is why it
//! reads gold and not white), and what travels is the brightness envelope, not any one star.
//! 1383 shipped a 23-sample stand-in for that population, chosen to keep the *coverage*
//! unbroken, and coverage was the wrong invariant: the samples tile the same path at 1/13th the
//! intensity, so the band came apart into legible marching sparkles (B228, the director's A/B
//! against the reference). Sampling saved cost the script layer was charging us; native, the
//! authored count is affordable, so there is nothing left to approximate.
//!
//! ## The clock — truncation and all
//!
//! The reference does NOT play this loop at wall-clock rate (decision 1321, B228's second round):
//! `CSimpleModel` advances its private scene clock by `__ftol(elapsed * 1000.0)` — truncated,
//! no fractional carry (`0x76d846`; the WORLD driver adds 0.5 first, `0x48366b`) — so a 2000 ms
//! band takes `2000·T/floor(T)` ms at frame time T ms: 2083 ms at 60 fps, 2315 at 144, never
//! less than 2000. [`ShineClock`] is that integer-millisecond accumulator, advanced the
//! reference's way. All shine sites share one clock: the ref's per-button Models are all created
//! at UI load with `SetSequence(0)` and never drift, the loop being exact — one shared clock IS
//! that behaviour.
use bevy::prelude::*;
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads};
/// The script layer's registration token: a `Texture` region whose path starts with this converts
/// to **no quads** — the conversion records a [`ShineSite`] at its rect/z instead, and
/// [`emit_shine`] draws there. Ours, not the install's — the string never reaches the resolver.
///
/// An optional `:<f32>` suffix carries the `<Model>`'s **`scale=`** ([`token_model_scale`]), which
/// is `SetModelScale` and is not the same on every button that wears this shine (1391).
pub(crate) const SHINE_TOKEN: &str = "benilla:autocast-shine";
/// Read a token's model scale, or `None` if `path` is not a shine token at all — the single
/// definition of what the token's grammar is, shared by the conversion (which records the site)
/// and the shipped-XML sweep (which must exempt exactly this and nothing that merely resembles
/// it, so a typo still fails the archive check).
pub(crate) fn token_model_scale(path: &str) -> Option<f32> {
match path.strip_prefix(SHINE_TOKEN)? {
"" => Some(1.2),
rest => rest.strip_prefix(':')?.parse().ok(),
}
}
/// The spark art — the M2's own emitter texture, resolved once per site at conversion time
/// through the same resolver every UI texture uses.
pub(crate) const STAR_TEXTURE: &str = "Interface\\Buttons\\GlowStar";
/// FrameXML units per model unit — `768 · 5/3`, **aspect- and resolution-independent** (wow-re
/// `modelframe-clip-and-scale.md` C2, confirmed exactly as 1321 recorded it): 1 model unit is
/// *not* 1 layout unit, because `0x76d1a0` always folds in `G48 · 5/3` (`76d1c6 0x41ae70(1.0)` =
/// G48 · `76d1cb fmul [0x80655c]` = f32(5/3)), and that `G48` cancels the `768·√(a²+1)` in the
/// FrameXML-unit definition. The star's half-extent ([`STAR`]) escapes that fold, which is the
/// whole reason the two constants no longer live in the same space.
const PER_MODEL_UNIT: f32 = 1280.0;
/// The rim square's side in FrameXML units at a given `SetModelScale`: the M2's authored
/// 0.02-unit bone square through [`PER_MODEL_UNIT`]. **30.72 at the pet bar's `scale="1.2"`,
/// 31.23 at the spell book's `scale="1.22"`** — the ref writes a different number on each
/// template and 1391 stopped averaging them into one constant.
fn side(model_scale: f32) -> f32 {
0.02 * PER_MODEL_UNIT * model_scale
}
/// The size ramp's authored **half-extents, in MODEL units** (`file+0x104`) — deliberately not
/// pre-projected like [`side`], because they do not take [`side`]'s projection (decision 1390;
/// wow-re `modelframe-clip-and-scale.md` C3, which corrects the FrameXML-unit constant 1386 wrote
/// here and confirms everything else in that record).
///
/// A particle quad is not built in model space and transformed with the model: `0x7b3d20`
/// installs an **identity view** for the whole of the emitter's vertex generation (`7b3e41` save
/// / `7b3e46` install / restored at the tail), so the `±half` that `0x7b2a50` adds from the
/// `±1.0` corner table to the already-transformed position is an **eye-space length**; and file
/// flag `+0x04` bit `0x200` gates off the one multiply by the emitter scale (`0x7b2ba6 je`, taken
/// because `0x7b2ba3 test ah,0x2` finds the bit clear — the two *ungated* readers of
/// `[emitter+0x264]` are the bounding sphere). So `SetModelScale(1.2)` reaches the bone square
/// but **not** the stars standing on it.
///
/// The consequence, and the reason this is a model-unit constant now: an eye-space length is
/// projected by the ortho leg, which maps the frame's *diagonal-normalized* layout rect to NDC
/// ±1 — so a star's size is **aspect-dependent where the path's is not**.
///
/// > full width = `2 · half · 768·√(a²+1)` FrameXML units = **`2 · half · √(W² + H²)` window px**
///
/// The pixel form is the one [`site_quads`] uses: no UI scale, no frame scale, just the screen
/// diagonal. That is 12.80 px at 1024×768 — the number 1386 froze into a constant — but **14.69
/// px at 1280×720**, 22 % larger against a button 4 % *smaller*. The reference's shine really is
/// fatter on a widescreen; the 1280 that 1321 folded back is the 4:3 case of a law, not the law.
const STAR: [f32; 3] = [0.005, 0.0015, 0.001];
/// The in-plane billboard spin, radians per second of age (`file+0x198`, `angle = spin · age`,
/// closed form at `0x7b2ddc`) — 0.1 rad = 5.73° over a particle's whole life. The per-particle
/// sign flip that `0x7b2dea` would apply is gated by runtime bit `0x8000` ← file `+0x04` bit
/// `0x200`, which is CLEAR here, so **every** star turns the same way (wow-re C4: the `test
/// bl,0x20` behind it reads slot parity, not a stored field — there is no RNG in it).
const SPIN: f32 = 0.1;
/// The over-life colour ramp's three keys, `{r, g, b, a}` — the emitter's own.
const COLOR: [[f32; 4]; 3] = [
[0.976, 0.875, 0.192, 1.0],
[0.996, 0.945, 0.745, 1.0],
[1.000, 1.000, 1.000, 0.0],
];
/// The bone loop, milliseconds (seq 0's authored 2.000 s band).
const PERIOD_MS: u32 = 2000;
/// The bone loop in seconds, for the phase/age arithmetic.
const PERIOD: f32 = 2.0;
/// A particle's lifespan, seconds (`file+0xc0`).
const LIFE: f32 = 1.0;
/// The authored emission rate, particles per second (`file+0xdc`).
const RATE: f32 = 300.0;
/// One emitter per bone corner.
const EMITTERS: usize = 4;
/// Where the over-life ramps break from key 0→1 to key 1→2 (`file+0x14c`).
const MID: f32 = 0.5;
/// Sample a 3-key over-life ramp at normalized age `u` — the reference's own two-segment law
/// (`age > midPoint·lifespan` picks the segment, `0x7b5aaa`) with the inset it applies to the
/// segment-local time (`t = u·0.99 + 0.005`, `0x7b9b10`), so the authored endpoint values are
/// **never exactly reached** — a newborn star is already 0.5 % of the way toward the mid key.
/// Tiny, and carried because it is free and because a golden that pins the authored endpoints
/// pins something the reference never draws.
fn ramp(keys: [f32; 3], u: f32) -> f32 {
let (a, b, seg) = if u <= MID {
(keys[0], keys[1], u / MID)
} else {
(keys[1], keys[2], (u - MID) / (1.0 - MID))
};
let t = seg * 0.99 + 0.005;
a + (b - a) * t
}
/// [`ramp`] over the colour keys, all four lanes.
fn ramp_color(age: f32) -> [f32; 4] {
std::array::from_fn(|i| ramp([COLOR[0][i], COLOR[1][i], COLOR[2][i]], age))
}
/// Where an emitter sits at lap fraction `f` (wrapped into 0..1), as a FrameXML-unit offset from
/// the button's BOTTOM-LEFT (the model's own origin — module doc). The bones walk the square
/// clockwise on screen from that corner, one edge per quarter-lap.
fn point(f: f32, side: f32) -> (f32, f32) {
let f = f - f.floor();
let edge = f * 4.0;
let leg = edge.floor();
let t = edge - leg;
match leg as u32 {
0 => (0.0, t * side), // bottom-left → top-left
1 => (t * side, side), // top-left → top-right
2 => (side, side - t * side), // top-right → bottom-right
_ => (side - t * side, 0.0), // bottom-right → bottom-left
}
}
/// How many particles one emitter keeps alive — the emitter's own steady state, `rate × life`
/// (decision 1386: the population IS the look, and 1383 shipped a 23-sample stand-in for it).
///
/// **Both of the spawn driver's multipliers are byte-confirmed 1.0 for this widget** (wow-re
/// `modelframe-particle-density.md`): the camera-distance LOD
/// (`clamp(1 − (dist − 50)·0.02, 0.25, 1.0)`) sits at 1.0 *structurally*, because
/// `CSimpleModel::Draw` ZEROES the eye it is measured against (`0x76d3da`), leaving
/// `dist ≈ 0.02` layout units against a 50-unit inner radius — a UI model would have to be 50
/// screen diagonals away to fall off; and `particleDensity` defaults to 1.0 (`0x82e92c`) with no
/// UI exemption. The emitter's pool is `trunc(life·rate·1.15)` = 345 slots, so the 300 never hit
/// a cap either. `WOW_SHINE_N=<n>` stays as the A/B instrument, not as a pending question.
fn live_per_emitter() -> usize {
static N: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
std::env::var("WOW_SHINE_N")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or((RATE * LIFE).round() as usize)
});
*N
}
/// A multiplier on the authored star width (`WOW_SHINE_SIZE=<f>`, default 1.0) — an A/B
/// instrument, not a pending question: the projection it used to bracket is settled (1390).
///
/// The band's saturated THICKNESS is set by the quad width and its brightness by width × count,
/// so the two trade off and neither can be read off a capture alone — which is exactly why this
/// stays. `WOW_SHINE_SIZE=0.817` reproduces the pre-1390 4:3 sizing on a 16:9 window, so a
/// reference shot captured at 4:3 can be compared against ours without rebuilding.
/// **Never a tuning knob to leave dialled**: the shipped value is the one the bytes say.
fn size_scale() -> f32 {
static S: std::sync::LazyLock<f32> = std::sync::LazyLock::new(|| {
std::env::var("WOW_SHINE_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1.0)
});
*S
}
/// The live population, computed once: one entry per particle, oldest last, carrying everything
/// its FIXED age fixes — its age, its **model-unit half-extent** ([`STAR`]; the site's screen
/// diagonal turns it into pixels at emit), and its colour+alpha. Only the position moves per
/// frame, which is what keeps a 1200-quad emitter cheap.
///
/// The ages are the steady state of a `rate`/s emitter: one particle born every `1/rate` seconds,
/// none older than `life`. **Our one departure from the reference's own timeline**: it spawns in
/// per-frame batches (≈5 coincident particles per frame at 60 fps, at whatever position the bone
/// held that frame), where this spreads the same population evenly along the path. At the
/// authored star width against a 0.2-unit spawn pitch the two are the same picture; unlike a
/// sample count, it costs nothing to revisit, because the population is the file's.
static PARTICLES: std::sync::LazyLock<Vec<(f32, f32, [f32; 4])>> = std::sync::LazyLock::new(|| {
let size_scale = size_scale();
(0..live_per_emitter())
.map(|k| {
let age = k as f32 / RATE;
let u = (age / LIFE).min(1.0);
(age, ramp(STAR, u) * size_scale, ramp_color(u))
})
.collect()
});
/// One autocasting button's shine site, recorded by the extract's conversion when it meets a
/// shown [`SHINE_TOKEN`] texture (the minimap-slot pattern): everything [`emit_shine`] needs to
/// draw there without asking the script layer anything.
#[derive(Clone, PartialEq)]
pub(crate) struct ShineSite {
/// The token region's resolved rect — the BUTTON's rect (the token is SetAllPoints on it) —
/// in window pixels, y-down.
pub(crate) rect: Rect,
/// The token's paint-order key, verbatim — sparks draw exactly where the old spark textures
/// drew (the button's OVERLAY layer).
pub(crate) z: u64,
/// The token's clip rect, if any (a scroll-framed ancestor), window px y-down.
pub(crate) clip: Option<Rect>,
/// The token's effective UI alpha (parent chain folded), multiplied into every spark.
pub(crate) alpha: f32,
/// FrameXML-units → window-pixels factor at the conversion that recorded this site (the
/// extract's `s`); a resize re-extracts and re-records. Scales the bone PATH ([`side`]).
pub(crate) scale: f32,
/// The `<Model>`'s `SetModelScale`, read off the token ([`token_model_scale`]) — the ref
/// writes `1.2` on the pet button and `1.22` on the spell book's, so it belongs to the SITE
/// and not to a module constant (1391). Scales the bone path only; never the stars.
pub(crate) model_scale: f32,
/// The window's diagonal in the same pixels, `√(W² + H²)` — what scales the STARS, and
/// nothing else does (decision 1390; [`STAR`]'s law is in screen px, free of UI scale and
/// frame scale). Recorded beside `scale` so a resize re-answers both at once.
pub(crate) diag: f32,
/// `Interface\Buttons\GlowStar`, resolved at record time.
pub(crate) texture: Handle<Image>,
}
/// This frame's shine sites. Refilled by every FULL conversion (and left alone by the settled /
/// spliced paths — the token entries didn't change, so last conversion's sites are still true;
/// the token arm is deliberately not splice-simple, so any token edge reaches the full path).
#[derive(Resource, Default)]
pub(crate) struct ShineSites(pub(crate) Vec<ShineSite>);
/// The shared integer-millisecond loop clock (module doc: the reference's truncating
/// accumulator, decision 1321 — `floor(elapsed_ms)` added per frame, no fractional carry, so the
/// lap tracks the player's frame rate exactly as the reference widget's does).
#[derive(Resource, Default)]
pub(crate) struct ShineClock {
ms: u32,
}
impl ShineClock {
/// Advance by one frame's wall delta (seconds) and return the clock, in `0..PERIOD_MS`.
fn advance(&mut self, elapsed: f64) -> u32 {
self.ms = (self.ms + (elapsed * 1000.0).floor() as u32) % PERIOD_MS;
self.ms
}
}
/// Append every site's sparks to the UI quad overlay lane — [`UiQuadAppend`], beside the minimap
/// and the V-plates: re-emitted each frame, diffed by the mesh rebuild, never touching the
/// script layout. Runs (and advances the clock) unconditionally; with no sites it is two reads
/// and an add.
fn emit_shine(
mut clock: ResMut<ShineClock>,
sites: Res<ShineSites>,
time: Res<Time<Real>>,
mut quads: ResMut<UiQuads>,
) {
let ms = clock.advance(time.delta_secs_f64());
for site in &sites.0 {
site_quads(site, ms, &mut quads.overlays);
}
}
/// One site's sparks at clock `ms`, appended to `out` — [`emit_shine`]'s whole body, pure so the
/// tests drive it at exact millisecond clocks the way the old Lua goldens drove the `OnUpdate`.
fn site_quads(site: &ShineSite, ms: u32, out: &mut Vec<UiQuad>) {
// THE WIDGET IS ITS OWN SCISSOR (decision 1387). A `<Model>` does not draw into the UI's
// shared space: `0x76d240` saves the viewport, installs the frame's OWN device rect as the
// viewport for the duration of the model's draw, and restores it after
// (`76d3cd` save · `76d5d7 0x58af60(left·x1, right·x1, bottom·y1, top·y1, 0, 1)` ·
// `76d66c` restore — wow-re `system/ui/scratch/modelframe-camera-law.md` §4 steps 3/6/9),
// while the ortho leg maps that same rect to NDC ±1 (§4b). Everything the model draws outside
// the rect is therefore cut by the clip volume, with a hard flat boundary at the rect edge.
//
// That is not cosmetic here, it IS the shine's silhouette: the authored 0.02 square projects
// to 30.72 units on a 30-unit button, so the bone path runs ON the rim and each 12.8-unit
// newborn star straddles it — half in, half out. Clipped, only the inward half survives, and
// the rim reads as the reference's does: a flat outer edge at the button border with ragged
// flame tongues bleeding inward over the icon. Unclipped, the same stars spill a soft halo a
// fifth of a button wide on all four sides — the director's A/B against the real client,
// which shows the stone behind the button completely unlit (B228).
let viewport = match site.clip {
Some(inherited) => site.rect.intersect(inherited),
None => site.rect,
};
if viewport.is_empty() {
return;
}
let lap = ms as f32 / PERIOD_MS as f32;
let s = site.scale;
let side = side(site.model_scale);
// The model origin: the button rect's bottom-left, in y-down px.
let (ox, oy) = (site.rect.min.x, site.rect.max.y);
for e in 0..EMITTERS {
// Bone `e` LAGS bone `e-1` by 500 ms — a quarter lap *behind*, not ahead (wow-re's bone
// table: bone 0 opens at BL, bone 1 at BR, bone 2 at TR, bone 3 at TL, all walking the
// one clockwise cycle BL→TL→TR→BR). Four identical emitters at four phases draw the same
// picture whichever way the index runs, which is exactly why the old `+e/4` could be
// wrong for two of them and look right; the law is worth being right about anyway.
let corner = -(e as f32) / EMITTERS as f32;
for &(age, w, c) in PARTICLES.iter() {
// The particle's fixed place in the loop: its emitter's quarter-lap offset, less how
// far the bone has moved on since this star was born (it is baked into the model
// frame at birth and never rides the bone — wow-re §7, flags bit 0x10 clear).
let (x, y) = point(lap + corner - age / PERIOD, side);
let (cx, cy) = (ox + x * s, oy - y * s);
// The path took `s`; the star takes the screen diagonal instead ([`STAR`]) — the two
// are different projections of the same model, not a shared one.
let half = w * site.diag;
out.push(UiQuad {
rect: Rect::new(cx - half, cy - half, cx + half, cy + half),
z_key: site.z,
texture: Some(site.texture.clone()),
color: [c[0], c[1], c[2], c[3] * site.alpha],
additive: true,
// The in-plane billboard spin ([`SPIN`]) — the one field 1321 recorded as live
// and left unmodelled for want of a rotation to hang it on. `UiQuad::rotation`
// is that rotation, and it turns the corners with their UVs, which is what the
// reference's screen-plane rotate does.
rotation: SPIN * age,
clip: Some(viewport),
..Default::default()
});
}
}
}
/// Resource + producer registration (the [`UiQuadAppend`] window — `ui_pass` sorts the lanes
/// together by `z_key`, so the sparks interleave exactly at the button's overlay layer).
pub(crate) struct AutocastShinePlugin;
impl Plugin for AutocastShinePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ShineSites>()
.init_resource::<ShineClock>()
.add_systems(Update, emit_shine.in_set(UiQuadAppend));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The pet button's rim square — the geometry every golden below was taken at
/// (`scale="1.2"`, the ref's own value on that template).
fn s12() -> f32 {
side(1.2)
}
/// The token's grammar (1391): it carries the `<Model>`'s `scale=`, because the reference
/// writes a different one on each template that wears this shine — and it still refuses
/// anything that merely looks like the token, which is what keeps the shipped-XML archive
/// sweep honest about a typo.
#[test]
fn the_token_carries_the_models_scale_and_nothing_else_parses_as_one() {
assert_eq!(token_model_scale("benilla:autocast-shine:1.2"), Some(1.2));
assert_eq!(token_model_scale("benilla:autocast-shine:1.22"), Some(1.22));
assert_eq!(
token_model_scale(SHINE_TOKEN),
Some(1.2),
"bare = the pet bar's"
);
for bad in [
"benilla:autocast-shin",
"benilla:autocast-shine2",
"benilla:autocast-shine:",
"benilla:autocast-shine:x",
"benilla:autocast-shine1.22",
"Interface\\Buttons\\GlowStar",
] {
assert_eq!(token_model_scale(bad), None, "{bad} must not parse");
}
// The two rim squares the shipped templates actually ask for.
assert!((side(1.2) - 30.72).abs() < 1e-4, "pet button");
assert!((side(1.22) - 31.232).abs() < 1e-4, "spell book");
}
/// The population golden (1386): the emitter keeps `rate × life` particles alive, aged one
/// spawn interval apart, none past its life — the file's own numbers, so a constant edit
/// cannot quietly re-open the sampling era.
#[test]
fn the_population_is_the_files_own_steady_state() {
assert_eq!(PARTICLES.len(), 300, "300/s × 1.0 s life");
assert_eq!(PARTICLES[0].0, 0.0, "the newest particle is newborn");
for w in PARTICLES.windows(2) {
let step = w[1].0 - w[0].0;
assert!(
(step - 1.0 / RATE).abs() < 1e-6,
"spawn interval {step} ≠ 1/{RATE}"
);
}
assert!(PARTICLES.iter().all(|p| p.0 < LIFE));
// Density is what the look rests on: the bone lays a particle every 0.20 units of rim
// while a newborn star is 12.8 units wide at 4:3, so ~60 stars pile onto any point of
// the band. Additive, that saturates — the reference's thick continuous rim, and exactly
// what 1383's 23 samples (7.7 units apart, one star deep) could not produce. Both terms
// are in FrameXML units here, so the star takes the 4:3 case of its own law (1390).
let pitch = 4.0 * s12() / PERIOD / RATE;
let depth = 2.0 * ramp(STAR, 0.0) * 1280.0 / pitch;
assert!(
depth > 50.0,
"only {depth:.0} stars deep — that will not saturate"
);
}
/// The newborn particle's quad, generated through the REAL producer body at exact
/// millisecond clocks: origin at the site rect's bottom-left, the quarter-edge midpoint
/// exactly half way (LINEAR, `interp == 1`), the ramps' own size and colour, the site's
/// alpha folded, and `4 × population` quads per site.
#[test]
fn a_sites_particles_come_out_where_the_bone_walked() {
let site = ShineSite {
rect: Rect::new(72.0, 56.0, 102.0, 86.0), // the pet bar's button 1, y-down px
z: 42,
clip: None,
alpha: 1.0,
scale: 1.0,
model_scale: 1.2,
diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
texture: Handle::default(),
};
let at = |ms: u32| {
let mut out = Vec::new();
site_quads(&site, ms, &mut out);
assert_eq!(out.len(), EMITTERS * PARTICLES.len());
// Emitter 0's newest particle is the first quad; its centre is the bone itself.
let r = out[0].rect;
(
(r.min.x + r.max.x) * 0.5 - site.rect.min.x,
site.rect.max.y - (r.min.y + r.max.y) * 0.5,
)
};
// The old Lua golden's own 0.01-px tolerance: the centre is reconstructed from the
// quad's f32 edges, so exact-bit equality is not the claim — the corner is.
let near = |got: (f32, f32), want: (f32, f32), what: &str| {
assert!(
(got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01,
"{what}: at {got:?}, expected {want:?}"
);
};
near(at(0), (0.0, 0.0), "clock 0: the bottom-left origin");
near(
at(250),
(0.0, s12() / 2.0),
"quarter edge in: the LEFT edge's midpoint (LINEAR)",
);
near(at(500), (0.0, s12()), "clock 500 ms: top-left");
near(at(1000), (s12(), s12()), "clock 1000 ms: top-right");
near(at(1500), (s12(), 0.0), "clock 1500 ms: bottom-right");
near(at(2000), (0.0, 0.0), "clock 2000 ms wraps onto clock 0");
// The newborn quad wears the ramp's birth values (the inset included — it is 0.5 % of
// the way to the mid key already), the additive blend the emitter authors, and the
// site's own paint order.
let mut out = Vec::new();
site_quads(&site, 0, &mut out);
let head = &out[0];
let width = head.rect.max.x - head.rect.min.x;
assert!((width - 2.0 * ramp(STAR, 0.0) * site.diag).abs() < 0.01);
assert!(width < 2.0 * STAR[0] * site.diag, "the inset bites");
assert_eq!(head.color, ramp_color(0.0));
assert!(head.additive);
assert_eq!(head.z_key, 42);
// The site's alpha folds into every particle on top of the ramp's own.
let dim = ShineSite { alpha: 0.5, ..site };
let mut dimmed = Vec::new();
site_quads(&dim, 0, &mut dimmed);
assert_eq!(dimmed[0].color[3], ramp_color(0.0)[3] * 0.5);
}
/// The widget's viewport IS the silhouette (1387): every spark carries the site rect as its
/// clip, an inherited scroll clip intersects with it rather than replacing it, and — the
/// point of the whole thing — the sparks really do straddle the rim, so the clip is load
/// bearing and not a decorative no-op.
#[test]
fn nothing_the_model_draws_escapes_the_widgets_rect() {
let site = ShineSite {
rect: Rect::new(72.0, 56.0, 102.0, 86.0),
z: 42,
clip: None,
alpha: 1.0,
scale: 1.0,
model_scale: 1.2,
diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
texture: Handle::default(),
};
let mut out = Vec::new();
site_quads(&site, 0, &mut out);
assert!(out.iter().all(|q| q.clip == Some(site.rect)));
// Unclipped, a newborn star hangs a long way past the border — half its 12.8 units plus
// the 0.36 the 30.72 path already overhangs. That overhang is the halo the reference
// does not have; the clip above is what removes it.
let escaping = out
.iter()
.filter(|q| !site.rect.contains(q.rect.min) || !site.rect.contains(q.rect.max))
.count();
assert!(
escaping > out.len() / 2,
"only {escaping}/{} sparks cross the rim — the path is not on the rim any more",
out.len()
);
let worst = out
.iter()
.map(|q| site.rect.min.x - q.rect.min.x)
.fold(f32::MIN, f32::max);
assert!(
(worst - ramp(STAR, 0.0) * site.diag).abs() < 0.01,
"the newborn star should hang exactly half its width past the left rim, not {worst}"
);
// A scroll-framed ancestor narrows the viewport; it never widens it.
let scrolled = ShineSite {
clip: Some(Rect::new(0.0, 0.0, 90.0, 200.0)),
..site.clone()
};
let mut out = Vec::new();
site_quads(&scrolled, 0, &mut out);
assert!(out
.iter()
.all(|q| q.clip == Some(Rect::new(72.0, 56.0, 90.0, 86.0))));
// Scrolled fully out of view, the site draws nothing at all.
let hidden = ShineSite {
clip: Some(Rect::new(0.0, 0.0, 10.0, 10.0)),
..site
};
let mut out = Vec::new();
site_quads(&hidden, 0, &mut out);
assert!(out.is_empty());
}
/// The two projections and the spin, pinned where they can't drift back into one another
/// (1386, corrected by 1390): the bone square is 1280/model-unit × the Model's 1.2 at every
/// aspect, the star is an eye-space length that takes the screen diagonal and neither scale,
/// and every particle spins the same way at `spin · age`.
#[test]
fn the_star_skips_the_models_scale_and_every_spin_turns_one_way() {
// The bone square: 1280 per model unit and the Model's 1.2, at EVERY aspect (C2).
assert!(
(s12() - 30.72).abs() < 1e-4,
"the bone square DOES take the 1.2"
);
// The star: `2 · half · √(W² + H²)` window px, no 1.2 and no UI scale — so unlike the
// square it MOVES with the window's shape (C3). 1024×768 is the case 1386 mistook for
// the whole law; 1280×720 is 22 % wider on a button 4 % narrower.
let width = |w: f32, h: f32| 2.0 * STAR[0] * (w * w + h * h).sqrt();
assert!((width(1024.0, 768.0) - 12.80).abs() < 0.01, "4:3");
assert!((width(1280.0, 720.0) - 14.69).abs() < 0.01, "16:9");
assert!(
width(1280.0, 720.0) > 1.14 * width(1024.0, 768.0),
"the aspect term is what 1386 froze out; it must be live"
);
// The square is 2.4 star-widths across at 4:3 — 1321's 2.0 was the 1.2 leaking into the
// star. It is only 2.1 at 16:9, and that drift IS the law, not a rounding.
assert!((s12() / width(1024.0, 768.0) - 2.4).abs() < 1e-3);
let site = ShineSite {
rect: Rect::new(0.0, 0.0, 30.0, 30.0),
z: 0,
clip: None,
alpha: 1.0,
scale: 1.0,
model_scale: 1.2,
diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
texture: Handle::default(),
};
let mut out = Vec::new();
site_quads(&site, 0, &mut out);
// Rotation is a pure function of age, one direction, reaching 0.1 rad at the end of life.
assert_eq!(out[0].rotation, 0.0, "a newborn star has not turned yet");
let oldest = &out[PARTICLES.len() - 1];
assert!(
(oldest.rotation - SPIN * (1.0 - 1.0 / RATE)).abs() < 1e-6,
"the oldest star has turned spin × its age"
);
assert!(
out.iter().all(|q| q.rotation >= 0.0),
"the sign-flip bit is clear on this asset — nothing counter-rotates"
);
}
/// The corner walk and the four bones' phasing — wow-re's own bone table
/// (`modelframe-animation-clock.md` §6): one clockwise cycle BL→TL→TR→BR walked by all four,
/// each successive bone index LAGGING the last by 500 ms, so at clock 0 bone 0 sits at BL,
/// bone 1 at BR, bone 2 at TR and bone 3 at TL.
#[test]
fn the_bones_walk_one_clockwise_cycle_each_lagging_the_last() {
assert_eq!(point(0.0, s12()), (0.0, 0.0), "bottom-left at 0 s");
assert_eq!(point(0.25, s12()), (0.0, s12()), "top-left at 0.5 s");
assert_eq!(point(0.5, s12()), (s12(), s12()), "top-right at 1.0 s");
assert_eq!(point(0.75, s12()), (s12(), 0.0), "bottom-right at 1.5 s");
assert_eq!(point(1.0, s12()), (0.0, 0.0), "home at 2.0 s");
// Where each emitter's newborn particle sits at clock 0, straight off the producer.
let site = ShineSite {
rect: Rect::new(0.0, 0.0, 30.0, 30.0),
z: 0,
clip: None,
alpha: 1.0,
scale: 1.0,
model_scale: 1.2,
diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
texture: Handle::default(),
};
let mut out = Vec::new();
site_quads(&site, 0, &mut out);
let born = |e: usize| {
let r = out[e * PARTICLES.len()].rect;
(
(r.min.x + r.max.x) * 0.5,
site.rect.max.y - (r.min.y + r.max.y) * 0.5,
)
};
let near = |got: (f32, f32), want: (f32, f32), what: &str| {
assert!(
(got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01,
"{what}: at {got:?}, expected {want:?}"
);
};
near(born(0), (0.0, 0.0), "bone 0 opens at BL");
near(
born(1),
(s12(), 0.0),
"bone 1 opens at BR — a quarter lap BEHIND bone 0",
);
near(born(2), (s12(), s12()), "bone 2 opens at TR");
near(born(3), (0.0, s12()), "bone 3 opens at TL");
}
/// Decision 1321's clock law, on the native accumulator: at a exact 60 fps the floor of
/// every 16.666… ms delta is 16 ms, so the 2000 ms band takes ⌈2000/16⌉ = 125 frames —
/// 2083 ms of wall time, never less than authored.
#[test]
fn the_clock_truncates_like_the_reference_widget() {
let mut clock = ShineClock::default();
let dt = 1.0 / 60.0;
let mut frames = 0;
loop {
frames += 1;
let before = clock.ms;
let now = clock.advance(dt);
if now < before {
break; // wrapped: one full lap
}
assert!(frames < 1000, "clock never wrapped");
}
assert_eq!(
frames, 125,
"a 2000 ms band takes 125 frames at 60 fps (2083 ms)"
);
// 144 fps truncates harder — 6.944 ms banked as 6 — so the same lap takes 334 frames
// (2315 ms of wall clock). A frame-rate-independent clock would land both at 2000 ms.
let mut clock = ShineClock::default();
let dt = 1.0 / 144.0;
let mut frames = 0;
loop {
frames += 1;
let before = clock.ms;
if clock.advance(dt) < before {
break;
}
assert!(frames < 1000, "clock never wrapped");
}
assert_eq!(
frames, 334,
"the same band takes 334 frames at 144 fps (2315 ms)"
);
}
/// The ramps read the M2's keys through the reference's own two-segment sampler — including
/// its `t = u·0.99 + 0.005` inset, which means the authored endpoints are approached and
/// never reached. Pinned as inequalities plus the inset's exact arithmetic, so the law is
/// what is asserted rather than three literals.
#[test]
fn the_ramps_read_the_m2s_keys_through_the_references_inset() {
let near = |got: f32, want: f32| assert!((got - want).abs() < 1e-4, "{got} vs {want}");
// Segment-local time 0 lands 0.5 % in; segment-local time 1 lands 0.5 % short. The
// reference's own branch is `age > midPoint·lifespan`, so age == mid is still the FIRST
// segment — its 0.995 end, not the second segment's 0.005 start.
near(ramp(STAR, 0.0), STAR[0] + (STAR[1] - STAR[0]) * 0.005);
near(ramp(STAR, MID), STAR[0] + (STAR[1] - STAR[0]) * 0.995);
near(ramp(STAR, 1.0), STAR[1] + (STAR[2] - STAR[1]) * 0.995);
assert!(ramp(STAR, 0.0) < STAR[0], "the newborn star never hits k0");
assert!(ramp(STAR, 1.0) > STAR[2], "the dying star never reaches k2");
// The size ramp only ever shrinks, and the alpha only ever fades after the mid key —
// the two facts the population's brightness envelope rests on.
let mut prev = f32::INFINITY;
for k in 0..=100 {
let w = ramp(STAR, k as f32 / 100.0);
assert!(w < prev, "the size ramp must shrink monotonically");
prev = w;
}
// Alpha holds at the authored 1.0 across the whole first segment (k0 and k1 are both
// opaque) and only fades over the second — the reason the band's bright half is its
// freshest half and the wave reads as travelling.
near(ramp_color(0.0)[3], 1.0);
near(ramp_color(MID)[3], 1.0);
assert!(ramp_color(1.0)[3] < 0.01, "a dying particle is ~invisible");
}
}
+342 -114
View File
@@ -15,8 +15,11 @@
//! probe is always first). Super held matches nothing;
//! - [`Kind::Held`] commands **latch** on the matching press and unlatch on the *base key's*
//! release — the reference's `runOnUp` movement law, which is why tapping Shift mid-run does
//! not stop you, and why a chat box taking focus stops movement (latches clear on the capture
//! edge — the reference's own focus handler, `0x514490`) without eating the release;
//! not stop you, and why **nothing the UI does stops you**: a chat box taking focus and a
//! fullscreen frame eating a key both suppress the *press* and release nothing already held
//! (decision 2196). The only things that end a latch are the base key's release, the
//! [stuck-latch sweep](latch_and_dispatch) that stands in for a release the window never saw
//! (OS focus loss, the loading cover), and the VM swap;
//! - [`Kind::Edge`]/[`Kind::EdgeUpDown`] run their 1.12 Lua bodies in the VM;
//! - [`Kind::Host`] lands in [`BindingsState::fired`] for engine consumers (chat open, TAB
//! targeting, nameplates, autorun, camera zoom, …).
@@ -47,7 +50,7 @@ use benilla_ui::script::keybind::{AddonBindingBody, KeybindCommand, KeybindReque
use benilla_ui::script::UiScript;
use crate::char_select::ClientState;
use crate::ui_script::{PlayerUiHover, PointerOverUi, UiKeyboardCapture};
use crate::ui_script::{PlayerUiHover, PointerOverUiPanel, UiKeyboardCapture};
pub(crate) mod chord;
pub(crate) mod commands;
@@ -131,8 +134,19 @@ pub(crate) struct BindingsState {
/// Accumulated analog amount per host command this frame (wheel notches; a key press adds
/// the reference's own 1.0 step) — the camera zoom's input.
amounts: Vec<(Cmd, f32)>,
/// Rising edge of the keyboard-capture gate last frame (internal: clears Held latches once).
was_typing: bool,
/// **The pressed-key set — what makes a press a REPEAT** (decision 2204). The reference
/// classifies auto-repeat off its own list of keys it believes are down (`0x4248b3`), *not*
/// off the Win32 `lParam` repeat bit — so the OS bit is not what this reads either.
///
/// Base keys, normalized ([`chord::normalize_key`]) exactly as [`latched`](Self::latched) is,
/// so `NUMPADENTER` repeating under a held `ENTER` is the repeat it looks like. Reconciled
/// against `ButtonInput` at the top of every pass, which is what wipes it on a window
/// activation change for free — and why a key still held across an alt-tab starts running
/// again on the way back, as it does in the reference.
///
/// **Keyboard only, and the type says so.** The mouse plane is read as edges
/// (`just_pressed`), which cannot repeat, so it needs no classification of its own.
down: Vec<KeyCode>,
}
impl BindingsState {
@@ -169,13 +183,14 @@ impl BindingsState {
}
/// Which files this session's bindings live in — the macros-files pattern
/// ([`crate::ui_macro::MacroFiles`]), resolved once the character is known.
/// ([`crate::ui_macro::MacroFiles`]), written by [`seed_bindings_for_vm`] and read by the save
/// verb. It carried a session-keyed `identity` memo while the character set was loaded from a
/// per-frame system and had to recognise "same character, new VM"; the seed runs exactly once per
/// VM by construction, so there is nothing left to dedupe (decision 2241).
#[derive(Resource, Default)]
struct BindingFiles {
account: Option<std::path::PathBuf>,
character: Option<std::path::PathBuf>,
/// Whose set 2 is loaded — **in the VM that is live now**. See [`load_character_bindings`].
identity: crate::ui_script::VmMemo<Option<(String, String)>>,
}
/// Label for this module's systems inside [`crate::ui_script::UiInput`] — the UI key feed is
@@ -194,20 +209,21 @@ impl Plugin for BindingsPlugin {
.add_systems(
Update,
(
// Once per **VM**, not once per process (decision 1290): a login builds a
// fresh one, and an unseeded VM has no command registry at all —
// `sync_dispatch` would build an empty map and every keybind in the session
// would be dead. Hence `Update` with a session-keyed claim rather than
// `PostStartup`, ordered ahead of the pair that reads what it registers.
seed_bindings.before(sync_dispatch),
// **The registry and both sets are seeded at the VM's birth, not here**
// (decision 2241): [`seed_bindings_for_vm`] runs inside
// `load_ingame_ui_on_world_entry`, before FrameXML and every addon. A
// session-keyed `Update` claim answered *which* VM but not *when* inside its
// life — and this table's readers are load-edge readers: stock
// `ActionButton_OnLoad` paints its hotkey corner from `GetBindingKey` at
// OnLoad, and an addon's `SetBinding` on a stock command needs the command to
// exist.
(sync_dispatch, latch_and_dispatch)
.chain()
.in_set(crate::ui_script::UiInput)
.in_set(BindingSet)
.before(benilla_world::schedule::WorldStage::Input)
.run_if(in_state(ClientState::InWorld)),
load_character_bindings.run_if(in_state(ClientState::InWorld)),
drain_binding_requests.after(load_character_bindings),
drain_binding_requests,
),
);
}
@@ -228,24 +244,32 @@ pub(crate) fn registry_commands() -> Vec<KeybindCommand> {
.collect()
}
/// Register the command registry with the engine table and seed the account set from disk — once
/// per VM, before any window opens.
fn seed_bindings(
script: Option<NonSendMut<UiScript>>,
mut files: ResMut<BindingFiles>,
mut seeded: Local<crate::ui_script::VmMemo<bool>>,
) {
let Some(mut script) = script else { return };
if !seeded.claim(&script) {
return;
}
/// **The keybinding table goes into the VM before a single interface file runs** (decision 2241,
/// through the seam 2240 established): the command registry, the account set, and the character's
/// own set if it has one — all of it in one call at the VM's birth.
///
/// This was two `Update` systems with session-keyed claims. That answered *which* VM the memory was
/// about (1290) and not *when inside its life* it ran, and since 2226 the whole interface load —
/// FrameXML, every addon's file scope, `ADDON_LOADED`, `VARIABLES_LOADED`, `PLAYER_LOGIN` — happens
/// inside one exclusive call that precedes the first `Update`. Two readers that costs:
///
/// - stock `ActionButton_OnLoad` ends in `ActionButton_UpdateHotkeys`, which paints the corner from
/// `GetBindingText(GetBindingKey(action), …)`. With no registry every hotkey corner painted blank
/// and only recovered on the `UPDATE_BINDINGS` `sync_dispatch` fires a frame later;
/// - an addon's own `Bindings.xml` rows ARE registered during the walk, so the table held *only*
/// those during the burst: `GetBindingKey("TOGGLEWORLDMAP")` answered nothing, `SetBinding` on a
/// stock command was a silent nil, and the addons' rows occupied the low indices the Key Bindings
/// window walks.
///
/// Seeding before the walk is also the reference's own order, and the engine was already built for
/// it: `seed_binding_set` keeps the diff by NAME as well as positionally precisely so a set can be
/// seeded before a command exists (1201), and `register_bindings` is idempotent per name, so the
/// addon rows the walk registers afterwards still land.
pub(crate) fn seed_bindings_for_vm(world: &mut World, script: &mut UiScript) {
script.register_bindings(&registry_commands());
files.account = crate::local_state::bindings_account_path();
if let Some(overrides) = read_diff(&files.account) {
script.seed_binding_set(1, Some(store::resolve(&overrides)));
} else {
script.seed_binding_set(1, Some(store::resolve(&[])));
}
let account = crate::local_state::bindings_account_path();
let overrides = read_diff(&account).unwrap_or_default();
script.seed_binding_set(1, Some(store::resolve(&overrides)));
script.load_binding_set(1);
// The pair, not just the first half: `SPECS` ∪ `ABSENT` is the client's whole 1.12 command
// surface, and a log line that says only how many landed cannot say how much is left
@@ -256,6 +280,32 @@ fn seed_bindings(
SPECS.len() + commands::ABSENT.len(),
commands::ABSENT.len()
);
// The character's own set, if the roster names one — its file existing makes it the active set,
// the reference's own rule. The identity is the same one the edge resolves for the AddOn enable
// state a few lines above this call; absent (a rigged or capture run) leaves set 2 unseeded,
// exactly as the old per-frame system's early return did.
let id = world
.get_resource::<crate::char_select::Roster>()
.and_then(crate::ui_macro::identity);
let character = id
.as_ref()
.and_then(|(realm, name)| crate::local_state::bindings_character_path(realm, name));
match read_diff(&character) {
Some(overrides) => {
script.seed_binding_set(2, Some(store::resolve(&overrides)));
script.load_binding_set(2);
info!("bindings: character-specific set loaded");
}
None => {
script.seed_binding_set(2, None);
script.load_binding_set(1);
}
}
if let Some(mut files) = world.get_resource_mut::<BindingFiles>() {
files.account = account;
files.character = character;
}
}
/// Read + parse one diff file; `None` when absent/unreadable (defaults).
@@ -271,37 +321,6 @@ fn read_diff(path: &Option<std::path::PathBuf>) -> Option<Vec<(String, Vec<Strin
}
}
/// Load the character-specific set once the roster names the character (the macros-load
/// pattern); its file existing makes it the active set, the reference's own rule.
fn load_character_bindings(
script: Option<NonSendMut<UiScript>>,
roster: Res<crate::char_select::Roster>,
mut files: ResMut<BindingFiles>,
) {
let Some(mut script) = script else { return };
let Some(id) = crate::ui_macro::identity(&roster) else {
return;
};
// Session-keyed (1290): re-entering the world as the SAME character still meets a fresh VM
// with no set 2 in it, so "same identity" is only a reason to skip within one VM.
if files.identity.get(&script).as_ref() == Some(&id) {
return;
}
files.character = crate::local_state::bindings_character_path(&id.0, &id.1);
*files.identity.get(&script) = Some(id);
match read_diff(&files.character) {
Some(overrides) => {
script.seed_binding_set(2, Some(store::resolve(&overrides)));
script.load_binding_set(2);
info!("bindings: character-specific set loaded");
}
None => {
script.seed_binding_set(2, None);
script.load_binding_set(1);
}
}
}
/// Drain the VM's queued binding requests.
///
/// Two of them. `Save` persists on the window's SaveBindings (Okay): write the set's diff; saving
@@ -425,7 +444,8 @@ fn latch_and_dispatch(
scroll: Res<AccumulatedMouseScroll>,
capture: Res<UiKeyboardCapture>,
hover: Res<PlayerUiHover>,
over_ui: Res<PointerOverUi>,
// The CHROME flag — its only reader here is the wheel branch, which says why.
over_ui: Res<PointerOverUiPanel>,
dispatch: Res<BindingDispatch>,
mut state: ResMut<BindingsState>,
mut same_vm: Local<crate::ui_script::VmMemo<bool>>,
@@ -512,23 +532,47 @@ fn latch_and_dispatch(
// latches or fires happen while armed.
}
// ── The typing edge ── a box taking focus stops movement (the reference's focus handler
// releases every direction bit, `0x514490`): Held latches clear once on the rising edge;
// EdgeUpDown latches stay armed — their release half still fires (the reference delivers
// the up of a pressed binding regardless).
let typing = capture.typing;
if typing && !state.was_typing {
state.latched.retain(|&(_, b)| match b {
Bound::Spec(c) => !matches!(SPECS[c.0 as usize].kind, Kind::Held),
// An addon's `runOnUp` latch is an EdgeUpDown pair by another name — its release half
// is a Lua body that must still run, so it stays armed for the same reason.
Bound::Addon(_) => true,
});
}
state.was_typing = typing;
// ── Who owns this frame's keys ── a focused EditBox eats every key for as long as it holds
// focus; a shown keyboard frame ate the particular keys in `capture.consumed`. Both suppress a
// PRESS below and do nothing else — **a UI focus change releases nothing already held**.
//
// This is where bug 2196 lived. The block here used to drop every [`Kind::Held`] latch on the
// rising edge of `capture.typing`, on a misreading of `0x514490` as "the reference's chat-focus
// handler": its sole caller `0x493058` hangs off the CSimpleTop root's WM_ACTIVATE callback
// slot (`[root+0x1134]`, event category 2, payload 0 = deactivate), so it is the **OS
// window-deactivate** handler, not a UI-focus one (wow-re `loading-screen-input-law.md`; the
// conflated phrasing was `rf79-autorun-cancel-set.md`'s "Chat EditBox / window focus" row).
// In the reference a focused box merely turns the movement handlers into no-ops and the
// direction bits are *frozen, not cleared* — so holding W and pressing ENTER keeps you
// running, and the world map eating the `M` that closes it keeps you running too. Both
// regressions were one clear.
//
// The window-deactivate clear needs no code of its own: bevy's `KeyboardFocusLost` →
// `ButtonInput::release_all` makes every latched base key read up, and the stuck-latch sweep
// at the bottom of this system turns that into real releases (the loading cover reaches it the
// same way, through `loading_screen::input`'s `swallow`).
//
// ── The pressed-key reconcile ── the reference keeps its own list of keys it believes are
// down and calls a press a REPEAT when the key is already on it (`0x4248b3`) — it never reads
// the Win32 `lParam` repeat bit. Ours is reconciled against bevy's button planes here, before
// this frame's messages are read, which buys two things at once (decision 2204):
//
// - a release the window never saw drops off the list, exactly as it drops off `latched` in
// the sweep below — the list cannot go stale and start swallowing real presses;
// - **WM_ACTIVATE's wipe of the pressed-key set comes for free.** `KeyboardFocusLost` →
// `ButtonInput::release_all` empties the plane, so a key still physically held across an
// alt-tab is no longer on the list when we come back, and its next auto-repeat is therefore
// a fresh DOWN that re-latches. That is the reference's own behaviour and not a happy
// accident: `0x514490` cleared the direction bits on the way out, and `0x424790`'s wipe of
// the pressed-key set is what lets the first repeat put them back — so you resume running
// without lifting the key.
state
.down
.retain(|&kc| physically_down(BindKey::Key(kc), &keys, &buttons));
// ── Keyboard ── press edges latch/fire (exact-modifier chord match, no repeats, gated on
// typing and the capture arm); release edges unlatch and fire the runOnUp up-half.
// ── Keyboard ── press edges latch/fire (exact-modifier chord match, no repeats, gated on the
// two ownership terms and the capture arm); release edges unlatch and fire the runOnUp up-half.
let typing = capture.typing;
for ev in keyboard.read() {
let key = chord::normalize_key(ev.key_code);
match ev.state {
@@ -545,7 +589,18 @@ fn latch_and_dispatch(
| KeyCode::ArrowUp
| KeyCode::ArrowDown
);
if armed || (typing && !arrow_exempt) || sup || ev.repeat {
// A keyboard frame's existence gate ate this one key (decision 1319) — the
// world map's fullscreen `OnKeyDown`, a cinematic, the stack-split spinner. Per
// key, so the map eating its own `M` leaves every other binding alone.
let eaten = capture.consumed.contains(&ev.key_code);
// **A repeat is a key we already believe is down** — the reference's own test, not
// the OS's repeat bit (2204). Registered before the gates below, so a key held
// through a focused chat box is still "down" and its repeats stay repeats.
let repeat = state.down.contains(&key);
if !repeat {
state.down.push(key);
}
if armed || (typing && !arrow_exempt) || eaten || sup || repeat {
continue;
}
if state.latched.iter().any(|&(k, _)| k == BindKey::Key(key)) {
@@ -569,6 +624,7 @@ fn latch_and_dispatch(
}
}
ButtonState::Released => {
state.down.retain(|&kc| kc != key);
release(
&mut state,
&mut script,
@@ -638,6 +694,11 @@ fn latch_and_dispatch(
scroll.delta.y / bevy::input::mouse::MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR
}
};
// **Over CHROME**, not over any UI at all: the wheel still zooms with the cursor on a
// nameplate, which is a mouse-enabled widget and not a panel (`PointerOverUiPanel`). Plates sit
// over exactly the things you look at, so the raw flag silently killed scroll-zoom wherever one
// happened to be — a regression of the day the plate became a widget (2148), found in the
// 2168 audit.
if wheel != 0.0 && !armed && !sup && !over_ui.0 {
let (key, amount) = if wheel > 0.0 {
(BindKey::WheelUp, wheel)
@@ -672,19 +733,17 @@ fn latch_and_dispatch(
}
}
// ── The stuck-latch sweep ── a release the window never saw (focus loss, the macOS
// modifier eater's cousin): any latch whose base key reads up in the input state unlatches
// now, firing its up-half so a pushed action button unsticks visibly.
// ── The stuck-latch sweep ── a release the window never saw: any latch whose base key reads
// up in the input state unlatches now, firing its up-half so a pushed action button unsticks
// visibly. This is also where the reference's two real bulk clears land, because bevy already
// zeroes `ButtonInput` for both: **OS window deactivate** (`KeyboardFocusLost` →
// `release_all` — `0x514490`'s `and eax,0xfffff00f`, the direction bits released while
// autorun survives) and the loading cover (`loading_screen::input`'s `swallow` — the
// world-enter cascade's `0x5144c0`, which clears everything). Nothing about the UI's own
// keyboard focus reaches here, and that is the point (2196).
let mut stuck: Vec<BindKey> = Vec::new();
for &(k, _) in &state.latched {
let up = match k {
BindKey::Key(kc) => {
!keys.pressed(kc) && !(kc == KeyCode::Enter && keys.pressed(KeyCode::NumpadEnter))
}
BindKey::Mouse(b) => !buttons.pressed(b),
BindKey::WheelUp | BindKey::WheelDown => true,
};
if up && !stuck.contains(&k) {
if !physically_down(k, &keys, &buttons) && !stuck.contains(&k) {
stuck.push(k);
}
}
@@ -716,6 +775,31 @@ impl BindingDispatch {
}
}
/// Is this base key physically down right now, per bevy's own button planes?
///
/// The one place that question is answered, because two callers ask it for two reasons and an
/// answer that drifted between them would be a stuck latch on one side or a lost repeat on the
/// other: the [stuck-latch sweep](latch_and_dispatch) (a release the window never saw) and the
/// pressed-key reconcile beside it ([`BindingsState::down`]).
///
/// `ENTER` is the case that needs saying: [`chord::normalize_key`] folds `NUMPADENTER` into it, so
/// the normalized key is down while *either* physical key is.
fn physically_down(
key: BindKey,
keys: &ButtonInput<KeyCode>,
buttons: &ButtonInput<MouseButton>,
) -> bool {
match key {
BindKey::Key(KeyCode::Enter) => {
keys.pressed(KeyCode::Enter) || keys.pressed(KeyCode::NumpadEnter)
}
BindKey::Key(kc) => keys.pressed(kc),
BindKey::Mouse(b) => buttons.pressed(b),
// A notch is a press and a release in one frame; it is never "held".
BindKey::WheelUp | BindKey::WheelDown => false,
}
}
/// One matching press: latch the held kinds, run/fire by class.
fn press(
state: &mut BindingsState,
@@ -847,7 +931,7 @@ mod tests {
app.add_plugins((MinimalPlugins, bevy::input::InputPlugin))
.init_resource::<UiKeyboardCapture>()
.init_resource::<PlayerUiHover>()
.init_resource::<PointerOverUi>()
.init_resource::<PointerOverUiPanel>()
.init_resource::<BindingsState>()
.insert_resource(BindingDispatch::test_defaults())
.add_systems(Update, latch_and_dispatch);
@@ -870,10 +954,22 @@ mod tests {
fn release_key(app: &mut App, k: KeyCode) {
key(app, k, bevy::input::ButtonState::Released, false);
}
/// A press the OS has flagged as auto-repeat. Whether it *acts* as one is ours to decide
/// (2204), which is the whole point of the tests that use this.
fn repeat_key(app: &mut App, k: KeyCode) {
key(app, k, bevy::input::ButtonState::Pressed, true);
}
fn state(app: &App) -> &BindingsState {
app.world().resource::<BindingsState>()
}
/// What a keyboard FRAME ate this frame — the list `feed_ui_input` rewrites every pass, which
/// this harness has no copy of, so it is **set** rather than pushed (an accumulating list would
/// keep suppressing a key the frame stopped eating rounds ago).
fn frame_ate(app: &mut App, keys: &[KeyCode]) {
app.world_mut().resource_mut::<UiKeyboardCapture>().consumed = keys.to_vec();
}
/// One addon's `Bindings.xml`, in the reference's own shape: a `runOnUp` binding whose single
/// body forks on `keystate`, and a one-shot beside it. Each half counts itself in a global, so
/// the assertions below read what the VM actually ran rather than what we told it to run.
@@ -902,7 +998,7 @@ mod tests {
app.add_plugins((MinimalPlugins, bevy::input::InputPlugin))
.init_resource::<UiKeyboardCapture>()
.init_resource::<PlayerUiHover>()
.init_resource::<PointerOverUi>()
.init_resource::<PointerOverUiPanel>()
.init_resource::<BindingsState>()
.init_resource::<BindingDispatch>()
.add_systems(Update, (sync_dispatch, latch_and_dispatch).chain());
@@ -1014,14 +1110,6 @@ mod tests {
);
}
/// A `runOnUp` addon latch survives a chat box taking focus, and its release half still runs.
///
/// The typing edge clears [`Kind::Held`] latches (the reference's focus handler releasing the
/// direction bits) and deliberately leaves [`Kind::EdgeUpDown`] armed, because the up half of
/// a *pressed* binding is delivered regardless of focus. An addon's `runOnUp` binding is that
/// same pair wearing one chunk, so it must follow the same rule — dropped on the focus edge,
/// it would leave whatever its down half started running forever, with no key left to press
/// to stop it.
/// **The alt-arrow exemption: you can turn while the chat box has focus.**
///
/// A focused EditBox swallows every key — that is the reference's own handler returning 1 on
@@ -1070,8 +1158,16 @@ mod tests {
);
}
/// **Nothing a box taking focus does releases what is already held** (2196), and a `runOnUp`
/// addon latch still delivers its up-half when the key finally goes up.
///
/// The reference's focused box turns the movement handlers into no-ops — the direction bits are
/// frozen, not cleared — so a held binding of ANY kind rides the focus change out. This test
/// used to assert the opposite for [`Kind::Held`] (`assert!(!pressed(MOVE_FORWARD))`), on the
/// misreading of `0x514490` as a chat-focus handler that 2196 corrects: its sole caller hangs
/// off the WM_ACTIVATE slot, so it is the OS window-deactivate clear.
#[test]
fn a_run_on_up_addon_latch_survives_the_typing_edge_and_still_releases() {
fn a_held_latch_rides_out_a_box_taking_focus_and_still_releases() {
let mut script = UiScript::new().expect("VM");
script.register_bindings(&registry_commands());
script.register_addon_bindings(
@@ -1087,18 +1183,28 @@ mod tests {
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
assert_eq!(lua_count(&app, "PROBE_DOWN"), 1);
// A box takes focus: movement stops, the addon's latch stays.
// A box takes focus. Both latches ride it out: the reference freezes the direction bits,
// it does not clear them.
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = true;
app.update();
assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
assert!(
state(&app).pressed(cmd::MOVE_FORWARD),
"holding W and opening the chat box keeps you running (2196)"
);
assert_eq!(
lua_count(&app, "PROBE_UP"),
0,
"the focus edge is not a release — nothing has run the up half yet"
);
// And the keys still stop when the player lets go, box focused or not.
release_key(&mut app, KeyCode::KeyW);
release_key(&mut app, KeyCode::KeyJ);
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
"releasing W stops you, while typing exactly as otherwise"
);
assert_eq!(
lua_count(&app, "PROBE_UP"),
1,
@@ -1170,7 +1276,7 @@ mod tests {
|n: &str| Cmd(SPECS.iter().position(|s| s.name == n).expect("registered") as u16);
let mut s = crate::ui_script::keybindings_tests::harness();
crate::ui_script::keybindings_tests::on_page(&mut s);
const ROW: &str = "OptionsFrameContainerBodyKeybindingsRow";
const ROW: &str = "BenillaOptionsFrameContainerBodyKeybindingsRow";
// Expand Movement and arm JUMP's first capsule — JUMP is the classic wheel bind, and one
// of the 1.12 commands that is NOT `runOnUp`, so the reference accepts the wheel on it.
s.run(&format!("{ROW}1Header:Click()")).expect("expand");
@@ -1178,7 +1284,7 @@ mod tests {
assert_eq!(
s.eval::<String>(&format!("return {ROW}9Description:GetText()"))
.unwrap(),
"JUMP"
crate::ui_script::keybindings_tests::label(&s, "BINDING_NAME_JUMP", "JUMP")
);
assert!(s.bind_capture_armed());
let mut app = vm_harness(s);
@@ -1260,6 +1366,61 @@ mod tests {
assert!(app.world().resource::<BindingsState>().latched.is_empty());
}
/// **Auto-repeat is classified off OUR pressed-key set, not the OS bit** (2204), and the
/// window-deactivate wipe is what makes a held key resume on the way back.
///
/// Three laws in one run, because they are one mechanism:
///
/// 1. a repeat of a key we already believe is down does NOT re-run its binding — the
/// reference's own test (`0x4248b3`), which matters for the kinds that never latch (a held
/// SPACE must jump once, not every 33 ms);
/// 2. a press carrying `repeat: true` that we do NOT have down is a fresh DOWN. The OS bit is
/// not the authority — the reference reads its own list and never the Win32 `lParam` bit;
/// 3. so after a window deactivate — bevy's `KeyboardFocusLost` → `release_all`, which is our
/// `0x514490`+`0x424790` pair — the first repeat of a key still physically held re-latches,
/// and **you start running again without lifting the key**, as the reference does.
#[test]
fn a_repeat_is_a_key_we_already_have_down_so_a_held_key_resumes_after_an_alt_tab() {
let mut app = harness();
press_key(&mut app, KeyCode::Space);
app.update();
assert!(state(&app).fired(cmd::JUMP), "the first press jumps");
// (1) A repeat of a key we have down fires nothing — JUMP never latches, so the
// pressed-key set is the only thing standing between a held SPACE and a jump per frame.
repeat_key(&mut app, KeyCode::Space);
app.update();
assert!(
!state(&app).fired(cmd::JUMP),
"a repeat of a key already down is not a press"
);
// Movement, so the resume below has something to observe.
press_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
// (3) The window is deactivated. Bevy empties the keyboard plane, which unlatches through
// the stuck-latch sweep AND empties our pressed-key set — the reference's two wipes.
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release_all();
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
"the deactivate releases the direction bits (0x514490)"
);
// (2)+(3) Back in the window, still holding W. The OS calls this a repeat; we do not have
// the key down any more, so it is a fresh press — and you run again without lifting it.
repeat_key(&mut app, KeyCode::KeyW);
app.update();
assert!(
state(&app).pressed(cmd::MOVE_FORWARD),
"the first repeat after re-activation re-latches (2204)"
);
}
#[test]
fn held_commands_latch_across_frames_and_release_per_base_key() {
let mut app = harness();
@@ -1439,19 +1600,25 @@ mod tests {
assert!(!state(&app).pressed(by_name("BONUSACTIONBUTTON1")));
}
/// **The typing gate blocks NEW presses and releases nothing already held** (2196).
///
/// The two halves used to be one: the gate's rising edge dropped every [`Kind::Held`] latch, so
/// holding W and pressing ENTER stopped you dead. It rests on nothing — `0x514490`, the clear
/// that reading cited, is the OS window-deactivate handler (sole caller `0x493058`, off the
/// WM_ACTIVATE callback slot), and the reference's focused box only makes the movement handlers
/// no-ops: the bits are frozen, not cleared.
#[test]
fn the_typing_gate_blocks_new_input_and_clears_held_latches_once() {
fn the_typing_gate_blocks_new_input_but_a_held_binding_keeps_running() {
let mut app = harness();
press_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
// A box takes focus: movement stops (the reference's focus handler releases the
// direction bits), and new presses do nothing.
// A box takes focus. You keep running, and new presses type instead of binding.
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = true;
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
"latches clear on the capture edge"
state(&app).pressed(cmd::MOVE_FORWARD),
"the capture edge is not a release — holding W keeps you running while you type"
);
press_key(&mut app, KeyCode::KeyX);
app.update();
@@ -1459,6 +1626,13 @@ mod tests {
!state(&app).fired(cmd::SIT_OR_STAND),
"typed keys are not bindings"
);
// Letting go still stops you, box focused or not.
release_key(&mut app, KeyCode::KeyW);
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
"the release is delivered regardless of focus"
);
// Focus drops; keys work again.
release_key(&mut app, KeyCode::KeyX);
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = false;
@@ -1468,6 +1642,60 @@ mod tests {
assert!(state(&app).fired(cmd::SIT_OR_STAND));
}
/// **The world map's shape** (2196, the director's report): a shown keyboard-enabled frame eats
/// the key that closes it, and that must cost the key its binding and nothing else.
///
/// `WorldMapFrame` is `frameStrata="FULLSCREEN" enableKeyboard="true"` with an `<OnKeyDown>`
/// that re-matches `GetBindingKey("TOGGLEWORLDMAP")` in Lua and calls `RunBinding` itself, so
/// under the existence gate (decision 1319) it consumes every key while shown — including the
/// `M` that closes it. benilla reported that consumption as `typing`, whose rising edge then
/// dropped the movement latch: holding W and tapping `M` twice left you standing in a closed
/// map. The consumption is per KEY now, and releases nothing.
#[test]
fn a_keyboard_frame_eating_its_own_toggle_key_does_not_stop_a_held_run() {
let mut app = harness();
press_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
// The map is open and eats this frame's `M` (its own Lua runs the toggle). `M` carries a
// `Kind::Edge` binding, which a no-VM harness cannot observe — so the suppression half is
// asserted on `X` below, and this leg asserts the half the report is about.
frame_ate(&mut app, &[KeyCode::KeyM]);
press_key(&mut app, KeyCode::KeyM);
app.update();
assert!(
state(&app).pressed(cmd::MOVE_FORWARD),
"the frame ate the toggle key; you are still running (2196)"
);
// The eaten key loses its binding…
release_key(&mut app, KeyCode::KeyM);
app.update();
frame_ate(&mut app, &[KeyCode::KeyX]);
press_key(&mut app, KeyCode::KeyX);
app.update();
assert!(
!state(&app).fired(cmd::SIT_OR_STAND),
"the frame ate this key: its binding must not also fire"
);
// …and only that key. The old whole-frame flag suppressed every binding in the frame.
release_key(&mut app, KeyCode::KeyX);
app.update();
frame_ate(&mut app, &[KeyCode::KeyM]);
press_key(&mut app, KeyCode::KeyX);
app.update();
assert!(
state(&app).fired(cmd::SIT_OR_STAND),
"consumption is per key, not per frame"
);
assert!(
state(&app).pressed(cmd::MOVE_FORWARD),
"and W has been held throughout"
);
}
#[test]
fn mouse_buttons_bind_only_over_the_world_and_the_wheel_respects_ui() {
let mut app = harness();
@@ -1521,7 +1749,7 @@ mod tests {
app.update();
assert!(state(&app).fired(cmd::CAMERA_ZOOM_IN));
assert_eq!(state(&app).amount(cmd::CAMERA_ZOOM_IN), 2.0);
app.world_mut().resource_mut::<PointerOverUi>().0 = true;
app.world_mut().resource_mut::<PointerOverUiPanel>().0 = true;
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
+99 -59
View File
@@ -129,10 +129,6 @@ pub(crate) mod cmd {
pub(crate) const TOGGLE_SHEATH: Cmd = by_name("TOGGLESHEATH");
pub(crate) const TOGGLE_AUTORUN: Cmd = by_name("TOGGLEAUTORUN");
pub(crate) const TOGGLE_RUN: Cmd = by_name("TOGGLERUN");
pub(crate) const OPEN_CHAT: Cmd = by_name("OPENCHAT");
pub(crate) const OPEN_CHAT_SLASH: Cmd = by_name("OPENCHATSLASH");
pub(crate) const REPLY: Cmd = by_name("REPLY");
pub(crate) const REPLY2: Cmd = by_name("REPLY2");
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = by_name("TARGETNEARESTENEMY");
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = by_name("TARGETPREVIOUSENEMY");
pub(crate) const NAMEPLATES: Cmd = by_name("NAMEPLATES");
@@ -191,34 +187,89 @@ const TABLE: &[Spec] = &[
None
),
// ── Chat (BINDING_HEADER_CHAT) ──────────────────────────────────────────────────────
spec!("OPENCHAT", CHAT, Kind::Host, Some("ENTER"), None),
spec!("OPENCHATSLASH", CHAT, Kind::Host, Some("/"), None),
spec!(
"OPENCHAT",
CHAT,
Kind::Edge("ChatFrame_OpenChat(\"\")"),
Some("ENTER"),
None
),
spec!(
"OPENCHATSLASH",
CHAT,
Kind::Edge("ChatFrame_OpenChat(\"/\")"),
Some("/"),
None
),
spec!(
"CHATPAGEUP",
CHAT,
Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):PageUp()"#),
Kind::Edge("ChatFrame_ChatPageUp()"),
Some("PAGEUP"),
None
),
spec!(
"CHATPAGEDOWN",
CHAT,
Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):PageDown()"#),
Kind::Edge("ChatFrame_ChatPageDown()"),
Some("PAGEDOWN"),
None
),
spec!(
"CHATBOTTOM",
CHAT,
Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):ScrollToBottom()"#),
Kind::Edge("ChatFrame_ScrollToBottom()"),
Some("SHIFT-PAGEDOWN"),
None
),
spec!("REPLY", CHAT, Kind::Host, Some("R"), None),
spec!(
"REPLY",
CHAT,
Kind::Edge("ChatFrame_ReplyTell()"),
Some("R"),
None
),
// The other reply: the last person YOU told, not the last who told you
// (`ChatEdit_GetLastToldTarget`, ChatFrame.lua l.1650). The memory was already being kept by
// the send path and read by nothing — 1745.
spec!("REPLY2", CHAT, Kind::Host, Some("SHIFT-R"), None),
spec!(
"REPLY2",
CHAT,
Kind::Edge("ChatFrame_ReplyTell2()"),
Some("SHIFT-R"),
None
),
// The combat-log four (Bindings.xml l.108-119): ChatFrame2's own paging and the reference's
// `ToggleCombatLog`, both FloatingChatFrame.lua's since the chat window became the
// reference's (1948). Chords from `bindings-cache.wtf`, account ONE.
spec!(
"COMBATLOGPAGEUP",
CHAT,
Kind::Edge("ChatFrame2:PageUp()"),
Some("CTRL-PAGEUP"),
None
),
spec!(
"COMBATLOGPAGEDOWN",
CHAT,
Kind::Edge("ChatFrame2:PageDown()"),
Some("CTRL-PAGEDOWN"),
None
),
spec!(
"COMBATLOGBOTTOM",
CHAT,
Kind::Edge("ChatFrame2:ScrollToBottom()"),
Some("CTRL-SHIFT-PAGEDOWN"),
None
),
spec!(
"TOGGLECOMBATLOG",
CHAT,
Kind::Edge("ToggleCombatLog()"),
Some("SHIFT-C"),
None
),
// ── Action bar (BINDING_HEADER_ACTIONBAR) ───────────────────────────────────────────
// The ref's runOnUp pair (Bindings.xml:121: DOWN shows the pushed visual, UP fires) —
// exactly what the old hardcoded number-row table sent.
@@ -307,10 +358,10 @@ const TABLE: &[Spec] = &[
None
),
// The stance/shapeshift row (ref ShapeshiftBar_ChangeForm(n)) — ours clicks the bar's own
// buttons, which carry the full form-switch law (StanceBar.xml).
// buttons, which carry the full form-switch law (stock BonusActionBarFrame.xml, 1938).
// ── The self-cast dozen (1.12 `Bindings.xml`:257-293) ───────────────────────────────
// The same two halves as ACTIONBUTTON, with `ActionButtonUp`'s second argument set: the
// reference's own `onSelf`, which `ActionBar.xml` has always forwarded to `UseAction`'s third
// reference's own `onSelf`, which stock `ActionButtonUp` forwards to `UseAction`'s third
// and the host used to drop (1745). `ALT-1`…`ALT-=` are byte-real from DefaultBindings.wtf,
// and they sit one modifier off the plain bar exactly as the reference lays them out.
spec!(
@@ -567,8 +618,9 @@ const TABLE: &[Spec] = &[
),
// ── The action-bar PAGES (1.12 `Bindings.xml`:395-431) ──────────────────────────────
// The bar is six pages of twelve (action slots 1..72) and it has been since 1500 shipped the
// multibars; `ChangeActionBarPage` and the `ActionBar_Page{Up,Down}` wrap are ActionBar.xml's
// own, quoted 1:1 from the reference. `SHIFT-1..6` and the SHIFT-arrow / SHIFT-wheel steps are
// multibars; `ChangeActionBarPage` is the engine's (it fires ACTIONBAR_PAGE_CHANGED and nothing
// else — 1938) and the `ActionBar_Page{Up,Down}` wrap is stock ActionButton.lua's. `SHIFT-1..6`
// and the SHIFT-arrow / SHIFT-wheel steps are
// byte-real from `WTF\\DefaultBindings.wtf`.
spec!(
"ACTIONPAGE1",
@@ -645,7 +697,7 @@ const TABLE: &[Spec] = &[
Some("SHIFT-MOUSEWHEELDOWN")
),
// The action-bar lock (decision 1136), the ref's own binding body verbatim (Bindings.xml:433-
// 439) — it flips the `LOCK_ACTIONBAR` uvar `ActionBar.xml` declares, the same global the
// 439) — it flips the `LOCK_ACTIONBAR` uvar OptionsFrame.xml declares (1938), the same global the
// Options window's Action Bars row writes. It sits here because the reference files it under
// this header (l.433 carries no `header=`, so it inherits l.121's ACTIONBAR), and it ships
// **unbound**: no `TOGGLEACTIONBARLOCK` line in any of the install's three
@@ -1017,6 +1069,24 @@ const TABLE: &[Spec] = &[
None,
None
),
// The battleground scoreboard and the battlefield minimap — the Interface block's last two
// (Bindings.xml l.630-635), both Lua-bodied: `ToggleWorldStateScoreFrame` is the stock
// WorldStateFrame.lua's since 1972 and `ToggleBattlefieldMinimap` UIParent.xml's (ref
// UIParent.lua l.216-221). Defaults byte-real from `WTF\DefaultBindings.wtf`.
spec!(
"TOGGLEWORLDSTATESCORES",
INTERFACE,
Kind::Edge("ToggleWorldStateScoreFrame()"),
Some("SHIFT-SPACE"),
None
),
spec!(
"TOGGLEBATTLEFIELDMINIMAP",
INTERFACE,
Kind::Edge("ToggleBattlefieldMinimap()"),
Some("SHIFT-M"),
None
),
// ── Miscellaneous (BINDING_HEADER_MISC) ─────────────────────────────────────────────
spec!(
"MINIMAPZOOMIN",
@@ -1086,6 +1156,16 @@ const TABLE: &[Spec] = &[
//
// On a Mac keyboard the token arrives as F13, which is the reference's own Mac mapping rather
// than an accommodation (`KEY_PRINTSCREEN_MAC = "F13"`); `super::chord` does the translation.
// The stock WorldFrame.lua's framerate readout (decision 1983): `FramerateLabel`/`FramerateText`
// toggled by `ToggleFramerate()`, refreshed off `GetFramerate()` every quarter second by
// `WorldFrame_OnUpdate`. CTRL-R is the install's own DefaultBindings.wtf chord (1804).
spec!(
"TOGGLEFPS",
MISC,
Kind::Edge("ToggleFramerate();"),
Some("CTRL-R"),
None
),
spec!(
"SCREENSHOT",
MISC,
@@ -1148,10 +1228,10 @@ const TABLE: &[Spec] = &[
None
),
// ── MultiActionBar (BINDING_HEADER_MULTIACTIONBAR) ──────────────────────────────────
// The two bottom bars' buttons (MultiBars.xml renders exactly these; 1.12's right bars
// The two bottom bars' buttons (stock MultiActionBars.xml renders exactly these; 1.12's right bars
// and their MULTIACTIONBAR3/4 commands stay out — honest tree). Ref bodies are the
// MultiActionButtonDown/Up runOnUp pair (Bindings.xml:799-966), transcribed in
// MultiBars.xml; shipped UNBOUND like the ref (no MULTIACTIONBAR* line in any of the
// MultiActionButtonDown/Up runOnUp pair (Bindings.xml:799-966), defined in stock
// MultiActionBars.lua; shipped UNBOUND like the ref (no MULTIACTIONBAR* line in any of the
// install's bindings-cache.wtf files). 1.12 files bar 2 under a BLANK spacer-header;
// both bars sit under the one MULTIACTIONBAR header here (1008, recorded).
spec!(
@@ -1775,49 +1855,9 @@ pub(crate) static ABSENT: &[Absent] = &[
["PitchDownStart", "PitchDownStop"],
"no keyboard pitch — see PITCHUP"
),
// ── Chat ────────────────────────────────────────────────────────────────────────────
absent!(
"COMBATLOGPAGEUP",
["ToggleCombatLog"],
"ChatFrame2 is a real frame that nothing writes into — the combat-log line pipeline does \
not exist, which is why 1.12's own ToggleCombatLog has no home here"
),
absent!(
"COMBATLOGPAGEDOWN",
["ToggleCombatLog"],
"nothing writes the combat log — see COMBATLOGPAGEUP"
),
absent!(
"COMBATLOGBOTTOM",
["ToggleCombatLog"],
"nothing writes the combat log — see COMBATLOGPAGEUP"
),
absent!(
"TOGGLECOMBATLOG",
["ToggleCombatLog"],
"nothing writes the combat log — see COMBATLOGPAGEUP"
),
// ── Action bar ──────────────────────────────────────────────────────────────────────
// ── Interface ───────────────────────────────────────────────────────────────────────
absent!(
"TOGGLEWORLDSTATESCORES",
["ToggleWorldStateScoreFrame"],
"no battlegrounds"
),
absent!(
"TOGGLEBATTLEFIELDMINIMAP",
["ToggleBattlefieldMinimap"],
"no battlegrounds"
),
// ── Misc ────────────────────────────────────────────────────────────────────────────
absent!(
"TOGGLEFPS",
["ToggleFramerate"],
"the only framerate readout here is the dev HUD's cost pill, which is behind \
`#[cfg(feature = \"dev\")]` and is an instrument, not a player display (perf/hud.rs) — \
there is no player-facing framerate to toggle. (0997 recorded this as \"the perf pill is \
always-on by design\"; the pill is not the thing TOGGLEFPS toggles.)"
),
// The nine `hidden="true" debug="true"` rows. Every one of them names an instrument benilla
// really has (the tri counter, the collision display, the portal draw, the perf pill) — they
// are absent because those instruments answer to the dev plane's chords (0702/1043), not to a
+76 -32
View File
@@ -13,9 +13,13 @@
//! row `0x6812b1`–`0x6812ca` inside `0x681070`, which the driver `0x483460` calls at
//! `0x48361d` (wow-re `water-frame-straddle.md` §1 + `unit-blob-shadow.md` Q1). `0x683dd0` is
//! the **M2 node drain**, and the same loop body ticks each node's object first
//! (`0x48160c call [obj vt+0x38]` → the selection ring) and gates its shadow second
//! (`0x48160c call [obj vt+0x38]` → the selection ring) and draws its shadow second
//! (`0x683ec3`) — so per unit the additive ring goes down and this modulate darkens it, never
//! the other way round (wow-re `decal-frame-slot.md`). So the shadow
//! the other way round (wow-re `decal-frame-slot.md`). That second step is **not** a gate:
//! both exits of the tick `0x481540` return 1, so `0x683ea5`'s `je` is dead — an earlier
//! reading of it as "the callback suppresses a hidden object's shadow" was wrong at the bytes
//! and is corrected in wow-re (2026-09-06). What the tick DOES do is write the alpha this draw
//! is about to read, 30 bytes later — which is the whole of §"Appearance" below. So the shadow
//! lands after terrain and WMO and **before** everything else: the footprint decals
//! (`0x483654`), the M2 opaque pass (`0x4836a6`), the water surfaces (phase 3, drawn *between*
//! the two M2 transparent passes) and both of those passes. Every transparent in the world
@@ -40,16 +44,46 @@
//! never appear). Full extents, no missing half/scale factor — the standing size IS the law.
//! - **Appearance**: multiplicative darken — `GL_DST_COLOR/GL_ZERO` with the fade riding the
//! combine, which is exactly the lane's `EffectBlend::Multiply` (`dst × lerp(1, src, α)`).
//! Vertex diffuse is **white** with α = the model's fade alpha (`[model+0x180]` — spawn/despawn
//! fades + the self first-person fade ride into the shadow); the darkness lives in the texture
//! RGB. Unlit, no fog, no depth write. The texture loads as the default `WorldArt`
//! Vertex diffuse is **white** with α = the model's **base** alpha — `CM2Model+0x180`, read
//! through the accessor `0x710ca0` (`fld [ecx+0x180]`) at `0x6d7fd6`, then clamped to `[0,1]`,
//! ×255, `__ftol`, packed `(a<<24)|0x00FFFFFF`. VERIFIED, and it is `+0x180` and not `+0x19c`:
//! over the whole draw every `0x19c` operand is the stack local `[ebp-0x19c]`, and `this` is
//! touched at exactly three `mov ecx,ebx; call` sites (box `0x711a20`, matrix `0x710600`,
//! alpha `0x710ca0`), which makes that list the complete field census.
//!
//! **What rides in, at the bytes** (wow-re, 2026-09-06): the 2 s appear ramp and the despawn
//! ramp (`obj+0xf4`, `0x613b1e`'s `0x7d0`), the self first-person fade, **and the CharProc-14
//! aura transition** — stealth's `0.3`, ghost/invisibility's `0.5`. The aura is not a second
//! channel: `0x60d180` drives the SAME `StartAlphaFade` (`0x614f80`) the appear fade uses, and
//! `0x614a90` writes `model+0x180 = obj+0x100 · obj+0xf4` unconditionally given a model handle.
//! A stealthed unit's blob shadow dims with its body, on the same 1000 ms cubic.
//!
//! **What does not**: the M2 animated colour / texture-weight tracks. The body's per-batch
//! alpha is `+0x19c · M2Color[i].alpha · M2TextureWeight[j].value` (`0x707aea`–`0x707b33`);
//! this draw reads `+0x180` raw, so an asset-authored transparency track fades the body and
//! leaves its shadow at full strength. [`benilla_world::model_fade::UnitRenderAlpha`] is that
//! same product and stops at the same place.
//!
//! The darkness lives in the texture RGB. Unlit, no fog, no depth write. The texture loads as the default `WorldArt`
//! `Rgba8Unorm`, so the modulate multiplies raw bytes in the gamma lane — the reference's own
//! arithmetic (0161).
//! - **Gating**: the reference's `shadowLOD` cvar {0,1} is the master toggle (default on) — we are
//! always-on; `shadowBias` (default 0.1) is its depth-bias knob — [`SHADOW_DEPTH_BIAS`] plays
//! that role here. No dead/mount/kind test exists on the draw path; **which** objects register
//! for shadows is an open RE item (`HANDOFF(-> object-layer)`) — v1 policy: every Player/Unit
//! entity with a built animated model (GameObjects/doodads excluded).
//! that role here. No dead/mount/kind test exists on the draw path, and **which** objects
//! register is now settled (wow-re, 2026-09-06 — the old `HANDOFF(-> object-layer)` is closed):
//! `[node+0x90]` bit `0x400` is `NOT(arg bit1)` (`0x670e94`), and `0x613e10` takes that arg off
//! `OBJECT_FIELD_TYPE` — `0xb` GAMEOBJECT, `2` DYNAMICOBJECT, `0` otherwise. **GameObjects and
//! DynamicObjects never cast a blob shadow; units, players and corpses always do.** Which is
//! the v1 policy this lane already ran (every Player/Unit with a built animated model), now a
//! byte-fact rather than a guess.
//!
//! **One benilla divergence, deliberate.** `0x6d78f0`/`0x6d7920` read no visibility state at
//! all — the body's draw flag `[CM2Model+0x50]` comes from `ShouldRender` at `0x48174e`, which
//! is consumed at `0x48161d`, *after* the tick — so the reference can and does draw a shadow
//! under an undrawn body (a unit whose skin composite is still unbaked, `0x477860`). We gate on
//! `InheritedVisibility` anyway, because benilla's exterior-scene election is not a mechanism
//! the reference has and left Tanaris mobs' shadows on the Caverns of Time floor (decision
//! 1277). Keeping that gate is the 1277 call; it is named here so it reads as a choice.
//!
//! One shadow record per unit, its projected triangles rebuilt only when the inputs move
//! ([`ShadowKey`]) and pushed onto the effect stream every shown frame — an idle unit costs a
@@ -59,15 +93,13 @@
use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::ecs::entity::{EntityHashMap, EntityHashSet};
use bevy::ecs::entity::EntityHashSet;
use bevy::prelude::*;
use crate::creature_anim::AnimData;
use crate::net::{Embodied, NetEntity};
use crate::player::CameraControl;
use benilla_world::decal::{DecalFrame, WorldDecal};
use benilla_world::lighting::WowLighting;
use benilla_world::model_fade::{fade_alpha, RenderFade};
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
@@ -162,7 +194,22 @@ fn sync_shadows(
),
>,
shadows: Query<(Entity, &BlobShadow)>,
// The reconciler's inputs move only when a unit's animated model arrives or leaves, or a
// unit becomes or stops being a mount child (the `units` filter's two terms); a frame with
// none of those has the same answer it had last frame, and used to pay a set build plus a
// walk of every unit for it.
grew: Query<(), Added<ModelAnimations>>,
mut shrank: RemovedComponents<ModelAnimations>,
mounted: Query<(), Added<crate::entities::mount::MountBody>>,
mut unmounted: RemovedComponents<crate::entities::mount::MountBody>,
) {
if grew.is_empty()
&& mounted.is_empty()
&& shrank.read().next().is_none()
&& unmounted.read().next().is_none()
{
return;
}
let mut shadowed = EntityHashSet::default();
for (entity, shadow) in &shadows {
// Owner gone or no longer eligible (model torn down) → the decal goes with it.
@@ -191,7 +238,6 @@ fn update_shadows(
video: Res<crate::video::VideoConfig>,
time: Res<Time>,
catalog: Option<Res<AnimData>>,
rig: Option<Res<CameraControl>>,
shadow_assets: Option<Res<ShadowAssets>>,
images: Res<Assets<Image>>,
decals: WorldDecal,
@@ -201,11 +247,8 @@ fn update_shadows(
lighting: Res<WowLighting>,
// …and the camera, for the realtime lane's EDGE fade (its cascade ends at `shadowDistance`).
cam_pos: Query<&GlobalTransform, With<WorldCamera>>,
// Spawn/despawn fades live on the model *part* entities; attribute each to its unit root so
// the shadow can ride the same alpha the body renders with (O(#currently-fading parts) — zero
// in the steady state).
fades: Query<(Entity, &RenderFade)>,
parents: Query<&ChildOf>,
// The unit's render alpha, asked of the unit — see the alpha block below.
unit_alpha: benilla_world::model_fade::UnitRenderAlpha,
owners: Query<
(
&Transform,
@@ -268,16 +311,6 @@ fn update_shadows(
// and folding the two would make the census answer "why did it hide" with a lie. This is the
// instrument's whole job (decision 1283).
let mut n_undrawn = 0u32;
let mut root_fade: EntityHashMap<f32> = EntityHashMap::default();
for (part, fade) in &fades {
let alpha = fade_alpha(fade.from, fade.to, (now - fade.started) / fade.duration);
let mut root = part;
while let Ok(child_of) = parents.get(root) {
root = child_of.parent();
}
let slot = root_fade.entry(root).or_insert(1.0);
*slot = slot.min(alpha);
}
let surface_count = decals.receiver_count();
for (shadow, mut key, mut verts) in &mut shadows {
n_total += 1;
@@ -350,11 +383,22 @@ fn update_shadows(
n_degen += 1;
continue;
}
let mut alpha = root_fade.get(&shadow.owner).copied().unwrap_or(1.0);
if is_self {
// The self first-person fade rides the same model-fade slot in the reference.
alpha *= rig.as_deref().map_or(1.0, CameraControl::self_fade);
}
// **The unit's render alpha, asked of the unit** — the shadow rides the model's fade slot
// (module docs), so it takes the engine's answer rather than reconstructing one.
//
// It used to gather the live `RenderFade`s off this unit's *part* entities and read "no
// part is fading" as `1.0`. That is true of a settled unit and exactly backwards for the
// state a unit spends its whole arrival in: a streamed unit holds `PendingAppearFade` —
// no `RenderFade` anywhere yet — until the world is actually shown, so its parts are
// deliberately invisible while this walk found nothing to attribute and drew the shadow
// **fully opaque**. A dark oval on the ground under no creature for the length of the
// load (2.9 s on a probe login), snapping to zero the instant the ramp armed, then easing
// back in with the body. `UnitRenderAlpha` asks the root, where `UnitAppearFade` tells
// pending from settled — the distinction a part-side walk cannot make.
//
// The self first-person fade comes with it (the reference rides the same slot), which is
// why there is no `is_self` term here any more.
let mut alpha = unit_alpha.get(shadow.owner);
// MONKEY (night blob): the realtime-lane handover rides the SAME alpha the spawn/despawn
// fades do — the oval is a Multiply decal, so scaling its alpha scales its darkening, and
// `ShadowKey::alpha` already re-arms the projection when it moves by a colour step. `1.0`
+11 -34
View File
@@ -19,7 +19,7 @@
//! - **`SpellVisualKit` field 14** (`kit+0x38`, the `ShakeID` column) → once per kit play, via
//! [`SpellKitShake`] and [`fire_kit_shakes`], which carries the mechanism and the one deviation.
//! - **the `$SHK` animation event** → its payload *is* a group id, fired ungated at the event's own
//! bone-transformed point ([`event_point`]).
//! bone-transformed point — the fired key's own, carried on the event (decision 1904).
//!
//! **`$SHK` is decoded by exactly two handlers**, hanging off the GameObject (typemask `0x20`,
//! `0x5f3e20`) and DynamicObject (`0x40`, `0x5d58c0`) trampolines — the dword `0x4b485324` occurs
@@ -106,7 +106,7 @@ use benilla_formats::{CameraShake, CameraShakeCatalog, SpellShakeGroup};
use crate::creature_anim::{
footfall_culls, footfall_side, move_flags, AnimSoundEvent, MovementState, SpellKitShake,
};
use crate::entities::{BoneAttach, Creatures};
use crate::entities::Creatures;
use crate::net::{Embodied, NetEntity, ObjectStore, Spline};
use crate::player::ViewSubject;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
@@ -288,19 +288,13 @@ struct Shakes(CameraShakeCatalog);
fn fire_shakes(
mut events: MessageReader<AnimSoundEvent>,
time: Res<Time>,
units: Query<(
&NetEntity,
&GlobalTransform,
Option<&BoneAttach>,
Option<&benilla_world::rig_anim::RigPose>,
)>,
units: Query<(&NetEntity, &GlobalTransform)>,
parents: Query<&ChildOf>,
roots: Query<(
Option<&ObjectStore>,
Option<&MovementState>,
Option<&NetEntity>,
)>,
joints: Query<&GlobalTransform>,
camera: Query<&GlobalTransform, With<WorldCamera>>,
creatures: Option<Res<Creatures>>,
shakes: Option<Res<Shakes>>,
@@ -322,7 +316,7 @@ fn fire_shakes(
if !thud && !shk && footfall_side(&ev.ident).is_none() {
continue; // `$FSD` is the sound handler's; only the VISUAL channel shakes
}
let Ok((net, transform, attach, pose)) = units.get(ev.entity) else {
let Ok((net, transform)) = units.get(ev.entity) else {
continue;
};
if shk {
@@ -341,8 +335,7 @@ fn fire_shakes(
// **No gates at all** — no distance, visibility, CVar or state test (the sibling
// `$DSL` arm and the footstep path both have them; this one has none). The position is
// the event's own bone-transformed point, not the object's origin.
let at = event_point(attach, pose, &joints, &ev.ident)
.unwrap_or_else(|| transform.translation());
let at = ev.pos.unwrap_or_else(|| transform.translation());
live.add_group(group, &shakes.0, at, now);
continue;
}
@@ -383,10 +376,11 @@ fn fire_shakes(
continue;
}
}
// The planted foot: the event's own marker through the live joint, exactly as the decal
// derives it. No marker/joint = the unit origin.
let foot = event_point(attach, pose, &joints, &ev.ident)
.unwrap_or_else(|| transform.translation());
// The planted foot: the FIRED key's own point, resolved once by the scanner exactly as
// the kernel snapshots it (decision 1904), not a by-4CC re-find of the marker table. The
// difference is real — 75 shipped models author some 4CC more than once at different
// points — and it is the same quantity the decal derives.
let foot = ev.pos.unwrap_or_else(|| transform.translation());
if footfall_culls(eye, foot) {
continue;
}
@@ -394,24 +388,6 @@ fn fire_shakes(
}
}
/// The world point an animation-event marker names on a live model — the planted foot for a
/// footfall tag, the authored shake point for `$SHK`. `None` when the model carries no marker for
/// the tag or has no live rig, and every caller falls back to the object's own origin.
///
/// This is the reference's own quantity: `placementMatrix · (boneMatrix[event.bone] ·
/// event.position)`, computed once by the M2 event kernel (`0x719370`) and carried by value in the
/// queue record it drains. The decal path derives it the same way, which is why the two share this.
fn event_point(
attach: Option<&BoneAttach>,
pose: Option<&benilla_world::rig_anim::RigPose>,
joints: &Query<&GlobalTransform>,
ident: &[u8; 4],
) -> Option<Vec3> {
let (a, p) = attach.zip(pose)?;
let (bone, offset) = a.markers.get(ident).copied()?;
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
}
/// Fire the camera shake a spell-visual kit's field 14 names — the **spell-side producer**
/// (decision 1849), the counterpart of `crate::sound::spell`'s kit-sound route.
///
@@ -864,6 +840,7 @@ mod tests {
..default()
};
let path = |grounded: bool| Spline {
deck: None,
points: vec![[0.0; 3], [1.0, 0.0, 0.0]],
start: std::time::Instant::now(),
duration: std::time::Duration::from_secs(1),
@@ -137,7 +137,9 @@ impl Plugin for DepthProbePlugin {
// Between the opaque pass (which draws `Opaque3d` *and* `AlphaMask3d`) and the
// transmissive/transparent ones. That is the depth as it stood when the opaque fight was
// decided, which is the question — see [`DepthReadbackNode`] for what reading it later
// gets you instead.
// gets you instead. The retained static pass draws BEFORE the opaque pass (2016), so
// its walls are in this read too; while it ran after, the graph left the two
// unordered and a probe could read a wall-less depth on some frames.
//
// `WOW_DEPTH_AFTER=1` moves the copy to AFTER the transparent pass — deliberately
// measuring the thing the placement note warns about: the depth the transparent pass
@@ -259,7 +261,7 @@ fn collect_quads(
.flat_map(|r| quads.verts[r.start as usize..r.end as usize].iter())
.map(|v| v.pos)
.collect();
for (index, quad) in pos.chunks_exact(4).enumerate() {
for (index, quad) in pos.as_chunks::<4>().0.iter().enumerate() {
let mut corners = [Vec2::ZERO; 4];
let (mut dmin, mut dmax, mut center) = (f32::MAX, f32::MIN, Vec3::ZERO);
let mut behind = false;
@@ -699,7 +701,7 @@ mod tests {
Mat4::perspective_infinite_reverse_rh(
std::f32::consts::FRAC_PI_4,
3200.0 / 1800.0,
benilla_world::view::CAM_NEAR,
benilla_world::view::NEARCLIP_DEFAULT,
)
}
+168 -29
View File
@@ -15,7 +15,7 @@ const WOLF_FACTION: u32 = 32;
/// The `name-water` fixture's unit: the same wolf, re-seated 25 yd along the water scenario's own
/// look bearing (`WATER_EYE` → `WATER_LOOK`) at the river surface, so its overhead name projects
/// onto the water *beyond* it.
const NAME_WATER_POS: [f32; 3] = [-9512.97, -331.29, 61.4];
pub(super) const NAME_WATER_POS: [f32; 3] = [-9512.97, -331.29, 61.4];
/// The lighting matrix's chest (decision 0744): `GameObjectDisplayInfo` 259,
/// `World\SkillActivated\Containers\TreasureChest01.mdx`. GameObject guids carry the `0xF110` high
@@ -51,9 +51,11 @@ pub(super) fn seed_ui_fixture(
mut selection: ResMut<crate::target::Selection>,
mut player: ResMut<crate::player::Player>,
// Bundled: Bevy systems cap at 16 top-level params — a nested tuple is one param.
(mut actions, mut bank): (
(mut actions, mut bank, mut exit, mut loading): (
ResMut<crate::ui_action::PlayerActions>,
ResMut<crate::ui_bank::BankOpen>,
MessageWriter<AppExit>,
ResMut<crate::loading_screen::LoadingScreen>,
),
) {
// A glue-screen capture has no world scenario, and no glue screen opens a UI fixture.
@@ -68,6 +70,22 @@ pub(super) fn seed_ui_fixture(
}
ctx.ui_seeded = true;
// **A UI capture with no script VM is not a capture — refuse it.** Every seed below opens its
// window by calling into the in-game UI, so with no VM they all fail the same way: a nil
// global, one `warn!` in a log full of pipeline chatter, a valid-looking PNG of a UI-less
// world, and exit 0. That is the false-negative shape `method.md` §6 exists to prevent, and it
// burned a session. `scenario_wants_ui` removed the cause (a `ui:` scenario no longer needs
// `WOW_CAPTURE_UI=1`); this is the tripwire for whatever else could leave the VM absent, and
// it exits non-zero the way the window-size refusal does (`video::warn_if_window_mismatch`).
if script.is_none() {
error!(
"capture: REFUSING this capture — scenario {:?} declares a UI fixture but no script VM exists, so its window cannot be opened and the shot would be a UI-less world wearing the scenario's name.",
scenario.name
);
exit.write(AppExit::error());
return;
}
// A creature guid whose entry bits (24–47) carry 90001 — the NameCache resolves vendor/NPC
// names by that entry, so inserting the name by entry makes the title path run for real.
const NPC_ENTRY: u32 = 90_001;
@@ -135,6 +153,15 @@ pub(super) fn seed_ui_fixture(
};
match fixture {
// Nothing to open — the UI being loaded (and `demo_unit_feed`'s synthetic player/target)
// IS the fixture. The `script.is_none()` refusal above still guards it: these scenarios
// photograph the player UI, so a run without a VM is as wrong for them as for any other.
UiFixture::Bare => {}
// The one fixture that opens no window: it raises the world-entry loading screen over the
// settled scene and pins it there, tip and all. `hold_for_capture` sets the `Pick` edge;
// `crate::game_tip::drive_game_tip` paints it on the next frame, and the stability watch
// settles on the held image.
UiFixture::LoadingTip => loading.hold_for_capture(scenario.map),
UiFixture::Merchant => {
names.insert_creature(
NPC_ENTRY,
@@ -231,8 +258,8 @@ pub(super) fn seed_ui_fixture(
gossip.quests = vec![(783, 0, 5, "Eagan Peltskinner".into())];
// One short option AND four that WRAP — the live shape both gossip bugs came in as
// (the director's screenshots), and a menu deliberately TALLER than the parchment so
// the capture covers the whole chain: the per-row auto-height
// (`BenillaGossipRow_Resize`), the scroll frame that contains the overflow, and the
// the capture covers the whole chain: the stock `GossipResize`'s per-row auto-height,
// the scroll frame that contains the overflow, and the
// scrollbar that appears with it. A fixture of one-line labels showed none of this —
// every row fit the template's 16 px and nothing ever overflowed.
let judgement = [
@@ -490,13 +517,16 @@ pub(super) fn seed_ui_fixture(
};
// Entry 1 (selected by first-valid auto-selection): in progress, one creature
// objective at 3/10 (the slot counter below) + one item objective (bags are empty in
// capture → 0/5), choice + fixed rewards, money.
// capture → 0/5), choice + fixed rewards, money. `quest_type: 1` is QuestInfo.dbc's
// "Elite" — the real 783 is a plain quest, but this fixture is the only thing that
// puts a row TAG in front of the capture, and the tag and the "(Complete)" state word
// are different branches of the same row string (entry 2 covers the other).
quest_log.insert_template(QuestTemplate {
quest_id: 783,
method: 2,
level: 2,
zone_or_sort: 12,
quest_type: 0,
quest_type: 1,
rep_objective_faction: 0,
rep_objective_value: 0,
next_quest_in_chain: 0,
@@ -641,6 +671,29 @@ pub(super) fn seed_ui_fixture(
seed_bag_window(&mut script, icons.as_deref());
seed_equipped_bags(&mut script, icons.as_deref());
}
UiFixture::Cooldown => {
let Some(mut script) = script else {
return;
};
seed_cooldown_filmstrip(&mut script, icons.as_deref());
}
UiFixture::CooldownShine => {
let Some(mut script) = script else {
return;
};
seed_cooldown_filmstrip(&mut script, icons.as_deref());
// The pet bar's autocast shine, raised by hand: no pet is fed here, so the bar's own
// show path never runs. The shine is the subject, not the bar's chrome.
if let Err(e) = script.run(
"PetActionBarFrame:Show()\n\
for i = 1, 4 do\n\
local b = getglobal(\"PetActionButton\"..i)\n\
if b then b:Show(); getglobal(b:GetName()..\"AutoCast\"):Show() end\n\
end",
) {
warn!("capture: ui-cooldown-shine failed to raise the autocast shine: {e}");
}
}
UiFixture::WorldMap => {
let Some(mut script) = script else {
return;
@@ -980,39 +1033,39 @@ pub(super) fn seed_ui_fixture(
// capture pins: the era chrome (nine-slice seams, right-edge straddle), the tab
// plates, the search-box seat, the category list art with Controls selected (the
// OnShow default), and the window's fit scale.
if let Err(e) = script.run("ShowUIPanel(OptionsFrame)") {
if let Err(e) = script.run("ShowUIPanel(BenillaOptionsFrame)") {
warn!("capture: ui-options seed failed to open the window: {e}");
}
}
UiFixture::OptionsAudio => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The Audio page (0957): register the real CVar set first — the hermetic capture has
// no CvarPlugin file load to race, and the rows must read real values, not the
// nil-tolerant zeros — then open and select through the live paths.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) =
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowAudio:Click()")
{
if let Err(e) = script.run(
"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowAudio:Click()",
) {
warn!("capture: ui-options-audio seed failed: {e}");
}
}
UiFixture::OptionsGraphics => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The Graphics page (0959), same posture as the Audio fixture: real CVar set, live
// open-and-select paths.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) =
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowGraphics:Click()")
script.run("ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowGraphics:Click()")
{
warn!("capture: ui-options-graphics seed failed: {e}");
}
}
UiFixture::OptionsChat => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The Chat page (1589), the page fixtures' posture: the real CVar set, then the live
@@ -1020,9 +1073,9 @@ pub(super) fn seed_ui_fixture(
// `ChatFrame.xml` declares at file scope, so a hermetic capture sees the shipped "0"
// and the row paints unchecked — which is the shipped default, not a missing load.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) =
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowChat:Click()")
{
if let Err(e) = script.run(
"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowChat:Click()",
) {
warn!("capture: ui-options-chat seed failed: {e}");
}
}
@@ -1044,17 +1097,19 @@ pub(super) fn seed_ui_fixture(
}
}
UiFixture::OptionsDropdownList => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// The dropdown list open (0992, re-seated onto Camera Following Style by 1649), same
// posture as the page fixtures: real CVar set, the live open-select-toggle path. The
// list's width settles from its OnUpdate a frame later (the kit's WIDTH SETTLE law) —
// inside the capture's settle frames.
// list's width lands inside the click that opens it — the stock kit's
// `UIDropDownMenu_Refresh` sizes every button from `normalText:GetWidth() + 60`
// (`UIDropDownMenu.lua` l.395-422) and the engine's measurer answers that getter in
// the call that asked, so there is no settle to wait out.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) = script.run(
"ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowControls:Click(); \
OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()",
"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowControls:Click(); \
BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()",
) {
warn!("capture: ui-options-dropdown seed failed: {e}");
}
@@ -1071,15 +1126,15 @@ pub(super) fn seed_ui_fixture(
script.register_cvars(crate::cvars::registered_pairs());
script.register_bindings(&crate::bindings::registry_commands());
if let Err(e) = script.run(
"ShowUIPanel(OptionsFrame); \
OptionsFrameCategoryListRowKeybindings:Click(); \
"ShowUIPanel(BenillaOptionsFrame); \
BenillaOptionsFrameCategoryListRowKeybindings:Click(); \
KeyBindings_ExpandSection(1, true); KeyBindingsPage_Update()",
) {
warn!("capture: ui-keybindings seed failed: {e}");
}
}
UiFixture::OptionsSearch => {
let Some(mut script) = script else {
let Some(script) = script else {
return;
};
// Mid-search (0984), same posture as the page fixtures: real CVar set, then the
@@ -1090,7 +1145,7 @@ pub(super) fn seed_ui_fixture(
// advance law's visual regression guard.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) = script.run(
"ShowUIPanel(OptionsFrame); OptionsFrameSearchBox:SetText(\"volume\"); OptionsFrameSearchBox:SetFocus()",
"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameSearchBox:SetText(\"volume\"); BenillaOptionsFrameSearchBox:SetFocus()",
)
{
warn!("capture: ui-options-search seed failed: {e}");
@@ -1230,9 +1285,9 @@ pub(super) fn seed_ui_fixture(
"ChatFrameEditBox:SetText(\"hello northshire\")\n\
ChatFrameEditBox:HighlightText(6, 16)\n\
ChatFrame1:SetScript('OnUpdate', function()\n\
BenillaFCF_TabResize(ChatFrame1Tab)\n\
PanelTemplates_TabResize(10, ChatFrame1Tab)\n\
ChatFrame1Tab:SetAlpha(1.0)\n\
for _, t in ipairs(BenillaFCF_Textures(1)) do t:SetAlpha(0.25) end\n\
FCF_SetWindowAlpha(ChatFrame1, 0.25, 1)\n\
end)",
) {
warn!("capture: ui-chatedit seed failed: {e}");
@@ -1269,7 +1324,7 @@ pub(super) fn seed_ui_fixture(
// a tab quad is measured against
let mode = std::env::var("WOW_TABHOVER").unwrap_or_else(|_| "1".into());
let select = if mode == "3" { 1 } else { 2 };
if let Err(e) = script.run(&format!("BenillaFCF_TabClick({select})")) {
if let Err(e) = script.run(&format!("FCF_SelectDockFrame(ChatFrame{select})")) {
warn!("capture: ui-chat-tabhover select failed: {e}");
}
script.resolve();
@@ -1301,6 +1356,15 @@ pub(super) fn seed_ui_fixture(
}
UiFixture::NameWater => {
use benilla_protocol::messages::ObjectFields;
// The subject is an NPC's overhead name, and `UnitNameNPC` registers "0" (1804's
// byte-read default), so without this the shot contains no name at all — which is
// exactly what it had contained since 1804 landed. Set through Lua, the way a player
// turns it on, so the sync drains it into `NameConfig` like any other CVar write.
if let Some(script) = script.as_deref_mut() {
if let Err(e) = script.run("SetCVar(\"UnitNameNPC\", \"1\")") {
warn!("capture: name fixture could not enable UnitNameNPC: {e}");
}
}
// The synthetic self player at the eye (the reaction lookup reads its store, and the
// name colour is that verdict).
const SELF_GUID: u64 = 0x51;
@@ -1410,6 +1474,79 @@ pub(super) fn seed_ui_fixture(
/// fixtures. The bag is a standalone addon (no `ShowUIPanel` path): drive the container snapshot
/// and purse directly, then open and paint. The feed (`crate::ui_items`) leaves bag 0 alone when
/// there is no `SelfPlayer` (net is disabled in capture), so this manual snapshot is not clobbered.
/// The pinned `GetTime()` value the cooldown filmstrip parks the VM's session clock at, seconds.
/// Large, because the reference's own `CooldownFrame_SetTimer` refuses a `start <= 0` and the
/// starts here are `now − fraction · span`.
const COOLDOWN_NOW_S: f64 = 100_000.0;
/// Each filmstrip slot's cooldown duration, seconds. Long enough that the settle window's few
/// seconds of VM clock move a phase by `~5e-4` of a step, so the shot is reproducible.
const COOLDOWN_SPAN_S: f64 = 10_000.0;
/// **The cooldown sweep as a filmstrip** (B379): sixteen backpack slots, each parked at its own
/// fraction of one very long cooldown, so the window shows sixteen points of the 1000 ms sweep at
/// once — the instrument for "what does the indicator actually draw", which no test can reach
/// (the engine tests prove the scrub, not the picture).
///
/// Game slot 1 renders TOP-LEFT and slot 16 bottom-right (`ContainerFrame_GenerateFrame` numbers
/// backwards — see [`seed_bag_window`]), so ascending slot is reading order and ascending
/// fraction reads as a filmstrip.
fn seed_cooldown_filmstrip(
script: &mut benilla_ui::script::UiScript,
icons: Option<&crate::entities::ItemDisplays>,
) {
// Park the session clock FIRST: `set_container` stores each triple against it, and
// `GetContainerItemCooldown`'s cold-at-expiry guard reads it.
if let Err(e) = script.run(&format!("__benilla_now = {COOLDOWN_NOW_S}")) {
warn!("capture: ui-cooldown failed to pin the session clock: {e}");
}
const DISP_STONE: u32 = 6418;
let texture = icons
.and_then(|i| i.catalog.get(DISP_STONE))
.and_then(|d| d.icon.clone());
let span_ms = COOLDOWN_SPAN_S * 1000.0;
let now_ms = COOLDOWN_NOW_S * 1000.0;
let mut slots = std::collections::HashMap::new();
for slot in 1u32..=16 {
// Sixteen phases across the sweep, biased off both ends: 0 is the uniform disc and 1 is
// the flash, and neither is what this instrument is looking at.
let fraction = (f64::from(slot) - 0.5) / 16.0;
slots.insert(
slot,
benilla_ui::script::ContainerSlot {
petition: None,
durability: None,
duration_ms: None,
bar_placeable: true,
texture: texture.clone(),
count: 1,
quality: Some(1),
item_id: 6948,
link: Some("|cffffffff|Hitem:6948|h[Hearthstone]|h|r".into()),
locked: false,
equip_slots: Vec::new(),
cooldown: Some(((now_ms - fraction * span_ms) as i64, span_ms as u32, true)),
readable: false,
creator: None,
flags: 0,
already_bound: false,
enchants: Vec::new(),
},
);
}
script.set_container(
0,
Some(benilla_ui::script::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
if let Err(e) = script.run("OpenBag(0)") {
warn!("capture: ui-cooldown failed to open the backpack: {e}");
}
}
fn seed_bag_window(
script: &mut benilla_ui::script::UiScript,
icons: Option<&crate::entities::ItemDisplays>,
@@ -1429,6 +1566,7 @@ fn seed_bag_window(
benilla_ui::script::ContainerSlot {
petition: None,
durability: None,
duration_ms: None,
bar_placeable: true,
texture: icon(disp),
count,
@@ -1525,6 +1663,7 @@ fn seed_equipped_bags(
|disp: u32, count: u32, name: &str, quality: u32| benilla_ui::script::ContainerSlot {
petition: None,
durability: None,
duration_ms: None,
bar_placeable: true,
texture: icon(disp),
count,
+66 -6
View File
@@ -45,7 +45,7 @@
//! ## Running one capture by hand
//! **Run through Cargo — never the built binary directly:**
//! ```text
//! WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-unitframes \
//! WOW_CAPTURE=ui-unitframes \
//! WOW_CAPTURE_OUT=/tmp/shot.png cargo run -q -p benilla
//! ```
//! (`WOW_DATA` is only needed for a non-standard install — the client finds one in the project
@@ -60,8 +60,11 @@
//! compile time, which fixed the capture and left a binary that worked only on the machine that
//! built it. 1175 deleted the path instead: every shader is compiled into the binary and addressed
//! `embedded://<crate>/shaders/…`, so there is no asset root left to resolve wrongly.
//! `WOW_CAPTURE_UI=1` opts the player UI into the shot (off
//! by default so world baselines stay UI-free; omit it for world-only scenes). `WOW_CAPTURE=list`
//! A **`ui-*` scenario opts the player UI in on its own** — it declares a `ui:` fixture, which is
//! this table saying the window is the subject ([`scenarios::ui_opted_in`], and read its doc
//! for why that is a correctness fix and not a convenience). `WOW_CAPTURE_UI=1` remains, for what
//! it was always actually for: painting the UI over a **world** scenario's shot. World baselines
//! stay UI-free by default either way. `WOW_CAPTURE=list`
//! prints the scenario names. `scripts/visual.sh` wraps all of this.
use std::path::Path;
@@ -89,6 +92,7 @@ mod phase_probe;
mod pick_probe;
mod probe_auction;
mod probe_bank;
mod probe_bg_queue;
mod probe_binder;
mod probe_book;
mod probe_castcancel;
@@ -98,12 +102,16 @@ mod probe_chest;
mod probe_clam;
mod probe_crossing;
mod probe_gm_ticket;
mod probe_goquest;
mod probe_guard_poi;
mod probe_mail;
mod probe_melee;
mod probe_model_camera;
mod probe_partner;
mod probe_rig;
mod probe_service;
mod probe_taxi;
mod probe_vendor_swap;
mod probes;
mod scenarios;
use crate::run_mode::CaptureMode;
@@ -114,6 +122,7 @@ pub(crate) use phase_probe::PhaseProbePlugin;
pub(crate) use pick_probe::PickProbePlugin;
pub(crate) use probe_auction::ProbeAuctionPlugin;
pub(crate) use probe_bank::ProbeBankPlugin;
pub(crate) use probe_bg_queue::ProbeBgQueuePlugin;
pub(crate) use probe_binder::ProbeBinderPlugin;
pub(crate) use probe_book::ProbeBookPlugin;
pub(crate) use probe_castcancel::ProbeCastCancelPlugin;
@@ -123,12 +132,16 @@ pub(crate) use probe_chest::ProbeChestPlugin;
pub(crate) use probe_clam::ProbeClamPlugin;
pub(crate) use probe_crossing::ProbeCrossingPlugin;
pub(crate) use probe_gm_ticket::ProbeGmTicketPlugin;
pub(crate) use probe_goquest::ProbeGoQuestPlugin;
pub(crate) use probe_guard_poi::ProbeGuardPoiPlugin;
pub(crate) use probe_mail::ProbeMailPlugin;
pub(crate) use probe_melee::ProbeMeleePlugin;
pub(crate) use probe_model_camera::ProbeModelCameraPlugin;
pub(crate) use probe_partner::ProbePartnerPlugin;
pub(crate) use probe_rig::ProbeRigPlugin;
pub(crate) use probe_service::ProbeServicePlugin;
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probe_vendor_swap::ProbeVendorSwapPlugin;
pub(crate) use probes::{
fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
JitterMeterPlugin, LiftCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin,
@@ -136,6 +149,7 @@ pub(crate) use probes::{
ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, RevealAuditPlugin,
SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin,
};
pub(crate) use scenarios::ui_opted_in;
use scenarios::GlueScreen;
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
@@ -545,9 +559,21 @@ fn resize_request() -> Option<(u32, u32)> {
/// doesn't uncap" was the *power state* withholding the grant, not the mode — no present mode
/// escapes that; `cpu_ms` on the probe line is the rail-proof metric. `WOW_PROBE_UNCAP=immediate`
/// re-runs the losing arm when macOS/wgpu move.
///
/// `WOW_PROBE_UNCAP=vsync` does not uncap at all: the leg keeps the player's present mode and
/// rails at the display's rate on purpose. It was built because the WindowServer's grant is not
/// ours to schedule — one sitting on the M2 Air read 123.5, 60.0, 60.0 and 87.5 fps across four
/// otherwise identical uncapped legs — and 1442's "railed-60.0 pairs only" rule discards every
/// pair the grant split. **Measured the same day, it is not a neutral instrument:** two
/// interleaved rounds at the Goldshire pin, no crowd, read `cpu_ms` 11.80 / 11.55 uncapped
/// (both railed 60.0 by the WindowServer anyway) against 14.65 / 12.43 under this arm — the
/// vsync wait costs the process CPU, and unevenly. So the default stays uncapped and a pair is
/// still accepted only when both legs happened to rail; this arm is for a sitting whose legs
/// refuse to rail at all, with the tax read against a same-arm baseline.
pub(crate) fn probe_uncap_mode() -> bevy::window::PresentMode {
match std::env::var("WOW_PROBE_UNCAP").as_deref() {
Ok("immediate") => bevy::window::PresentMode::Immediate,
Ok("vsync") => bevy::window::PresentMode::AutoVsync,
_ => bevy::window::PresentMode::AutoNoVsync,
}
}
@@ -766,6 +792,37 @@ impl Plugin for CapturePlugin {
minute: knob("WOW_VISTA_MIN", 720.0) as u32,
ui: None,
}
} else if name == "name-close" {
// The magnified overhead-name instrument (see `scenarios::NAME_CLOSE_AT`): the
// `name-water` wolf, orbited by knob and looked straight at, so the ONE variable
// is how many device pixels a glyph texel is drawn into. Not a golden scenario.
let knob = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let at = scenarios::NAME_CLOSE_AT;
// The name hangs `h` above the unit's FEET (the posed PlayerName attachment plus
// the block's own line of rise) — that point is what the camera orbits and aims at.
let name_at = [at[0], at[1], at[2] + knob("WOW_NAME_H", 1.4)];
let (dist, az, el) = (
knob("WOW_NAME_DIST", 4.0),
knob("WOW_NAME_AZ", 124.0).to_radians(),
knob("WOW_NAME_EL", 8.0).to_radians(),
);
Scenario {
name: "name-close",
map: Some(scenarios::MAP_AZEROTH),
eye: [
name_at[0] + dist * el.cos() * az.cos(),
name_at[1] + dist * el.cos() * az.sin(),
name_at[2] + dist * el.sin(),
],
look: name_at,
minute: 720,
ui: Some(UiFixture::NameWater),
}
// By name, EITHER table: the blessed six or an on-demand fixture. Only the sweep is
// narrowed — every old viewpoint is still capturable by name (decision 0632).
} else if let Some(&s) = SCENARIOS
@@ -782,7 +839,7 @@ impl Plugin for CapturePlugin {
.map(|s| s.name)
.collect();
eprintln!(
"WOW_CAPTURE={name:?} is not a known scenario; choose one of: {known:?}, {glue_known:?} (or fxview, waterfx)"
"WOW_CAPTURE={name:?} is not a known scenario; choose one of: {known:?}, {glue_known:?} (or fxview, waterfx, name-close)"
);
std::process::exit(2);
})
@@ -866,7 +923,8 @@ fn pin_scene(
roots: Query<&Transform, Without<WorldCamera>>,
mut cam: Query<&mut Transform, With<WorldCamera>>,
) {
perf.visible = false; // the perf HUD is default-on; suppress it for a pristine, UI-free shot
perf.visible = false; // hidden by default since 2099, but a session may have chorded it up —
// a capture is pristine and UI-free whatever the run did
// A glue screen has no world to light, no clock to pin and no camera to place. The shutter
// above needs none of that — it is watching the framebuffer.
let Some(scenario) = ctx.scenario else {
@@ -1030,7 +1088,9 @@ fn drive_capture(
if watch.stable >= stable_frames() || capped {
if let Some(px) = watch.prev.as_deref() {
if px
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.all(|p| p[0] == 0 && p[1] == 0 && p[2] == 0)
{
error!(
+8 -1
View File
@@ -140,10 +140,17 @@ fn shading_of(mat: Option<&WowModelMaterial>) -> String {
let e = &m.extension;
let c = m.base.base_color.to_srgba();
format!(
"wmo {:.0} fade {:.0} class {:.1} tint {:.3},{:.3},{:.3} \
"wmo {:.0} int {:.0} fade {:.0} class {:.1} tint {:.3},{:.3},{:.3} \
sidn {:.3},{:.3},{:.3} win {:.0} sunsel {:.2} bias {:.0} uv {:.4},{:.4} \
clutter {:.1},{:.1},{:.1},{:.1} base {:.3},{:.3},{:.3},{:.3} {:?}",
e.model_flags.x,
// `int` is `model_flags.z` — the LIGHT-LANE flag, and it was the one row of the packed
// uniforms this line did not print. Without `is_wmo` it selects the interior-prop lane,
// i.e. "this batch is lit by the SH probe its MeshTag names" rather than by the sky; with
// it, the WMO interior batch-class lanes `class` then splits. A hit that reads
// `wmo 0 int 1` is asking about a probe slot, and the `slot` reading of the tag beside it
// is the one that matters — which is the whole diagnosis of B373.
e.model_flags.z,
e.model_flags.y,
e.tint.w,
e.tint.x,
@@ -0,0 +1,210 @@
//! The battleground-queue live probe (`WOW_PROBE_BGQUEUE=1`) — the instrument that makes
//! "**log in while queued for a battleground**" a shape anyone can reproduce, inert without the
//! env.
//!
//! Decision 2232 audited the 29 drains that could still lose a login burst and argued this one
//! safe on paper: `SMSG_BATTLEFIELD_STATUS` is published as `UPDATE_BATTLEFIELD_STATUS` off a
//! `mem::take` latch that no `VmMemo` re-arms, so a message consumed in 2214's one-frame window
//! is gone — but [`crate::ui_battlefield::reset_on_world_enter`] re-sends
//! `CMSG_BATTLEFIELD_STATUS` on entry and the server answers slot by slot, so the queue heals
//! itself. That record named the argument **unexercised**, and the reason it stayed unexercised
//! is that the setup is three server-side gates deep, not that nobody thought to look.
//!
//! So: level past the bracket floor, GM-hop to the Stormwind Warsong Gulch battlemaster, greet
//! him on the real wire (`CMSG_BATTLEMASTER_HELLO` → `SMSG_BATTLEFIELD_LIST`), queue through the
//! guid that list carried (`CMSG_BATTLEMASTER_JOIN`), and report the slot the server put us in.
//! The probe then **leaves the body queued** — vmangos keeps the queue entry across a logout
//! (`BattleGroundQueue::PlayerLoggedOut` only marks it offline) and re-sends the status from
//! `Player::LoadFromDB`, i.e. inside the login burst. The NEXT plain login is the measurement;
//! this run is its fixture.
//!
//! **Why the guid and not `CMSG_BATTLEFIELD_JOIN`.** vmangos routes the bodyless join through
//! `RequestBgJoinQueue(ObjectGuid{}, …)`, whose `GetNPCIfCanInteractWith(battlemaster,
//! UNIT_NPC_FLAG_BATTLEMASTER)` cannot resolve an empty guid — it logs an anticheat line and
//! returns, silently. Only the battlemaster leg queues anyone, which is why this probe has to
//! stand next to one.
//!
//! **The undo is one line**, and it belongs here rather than in whatever record cites the probe:
//! the body stays queued until something dequeues it, so leave it clean with
//! `WOW_PROBE_LUA='AcceptBattlefieldPort(1,0)'` on a plain login — `GetBattlefieldStatus(1)` then
//! answers `none`. A body left queued is not harmful, only surprising: every later login on that
//! account carries an extra `SMSG_BATTLEFIELD_STATUS` in its burst.
//!
//! Non-combat. Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>`; `method.md`, "The local vmangos server"), and `WOW_NOSOUND=1` when
//! it runs unattended. One `timeout`'d run plus a grep for `PROBE bgqueue:` is the whole harness.
use bevy::prelude::*;
use super::probes::ProbeClock;
use crate::net::{ClientCommand, Guid, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::target::cursor_mode::npc_flags;
use crate::ui_battlefield::Battlefield;
use crate::ui_dialog_verbs::BattlefieldQueue;
/// Elfarran's spawn (vmangos `creature` guid 54614, entry 14981, map 0) — Stormwind's Warsong
/// Gulch battlemaster, `battlemaster_entry` row `14981 → bg_template 2`. The `.go xyz` target;
/// the battlemaster himself is then found in the streamed world by his npc flag, never by a guid.
const BATTLEMASTER_AT: [f32; 3] = [-8454.62, 318.85, 120.97];
/// Warsong Gulch's Map.dbc row — what `GetBattleGroundTypeIdByMapId` turns back into template 2.
const WSG_MAP: u32 = 489;
/// The level the probe body is raised to before queueing.
///
/// `battleground_template` carries one row per content patch, and the server picks the highest at
/// or below its own: WSG opens at **10** on patch 6, **20** on patch 5 and **21** on patch 3. 25
/// clears all three, so the probe never has to know which patch the local server is running —
/// a wrong guess there fails as a bare `LANG_YOUR_BG_LEVEL_REQ_ERROR` notification with no queue,
/// which reads exactly like a broken client.
const QUEUE_LEVEL: u32 = 25;
/// `UNIT_NPC_FLAG_BATTLEMASTER`'s service range is the ordinary NPC one; 15 yd is the hop's
/// settle radius, not a gate — the server applies its own interaction check.
const NEAR_YD: f32 = 15.0;
pub(crate) struct ProbeBgQueuePlugin;
impl Plugin for ProbeBgQueuePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<BgQueueProbe>()
.add_systems(Update, bg_queue_probe);
}
}
#[derive(Resource, Default)]
struct BgQueueProbe {
phase: Phase,
}
/// `Wait` → (levelled + GM hop sent) `Hopped` → (battlemaster found, hello sent) `Greeted` →
/// (the list landed, join sent) `Joined` → verdict → `Done`.
#[derive(Default, PartialEq)]
enum Phase {
#[default]
Wait,
Hopped {
sent_at: f64,
},
Greeted {
master: u64,
sent_at: f64,
},
Joined {
sent_at: f64,
},
Done,
}
// One Bevy system's full input set (the guard-poi probe's shape).
#[allow(clippy::too_many_arguments)]
fn bg_queue_probe(
time: ProbeClock,
mut probe: ResMut<BgQueueProbe>,
me: Query<&ObjectStore, With<SelfPlayer>>,
player: Res<Player>,
battlefield: Res<Battlefield>,
queue: Res<BattlefieldQueue>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<crate::net::NetCommands>,
) {
let Ok(store) = me.single() else {
return;
};
let now = time.elapsed_secs_f64();
match probe.phase {
Phase::Wait => {
// **Revive first, unconditionally.** `CanInteractWithNPC` refuses a dead player
// outright (`!IsAlive() && !VISIBLE_TO_GHOSTS`), and the refusal surfaces as an
// anticheat line about an "invalid creature" — which reads like a wrong guid, not
// like a corpse. A probe body parks where the last run left it, and this one is
// easy to leave drowned; `.revive` on a living character is a no-op.
let _ = net.0.send(ClientCommand::Chat {
kind: crate::net::ChatKind::Say,
target: None,
text: ".revive".to_string(),
});
// The level next: a body under the bracket floor is refused at the HELLO, before the
// list is ever built, so there would be nothing to queue through.
let level = store.0.unit_level().unwrap_or(0);
if level < QUEUE_LEVEL {
info!("PROBE bgqueue: level {level} — raising to {QUEUE_LEVEL} for the bracket");
let _ = net.0.send(ClientCommand::Chat {
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".levelup {}", QUEUE_LEVEL - level),
});
}
let [x, y, z] = BATTLEMASTER_AT;
info!("PROBE bgqueue: hopping to the Warsong Gulch battlemaster at ({x}, {y}, {z})");
let _ = net.0.send(ClientCommand::Chat {
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
});
probe.phase = Phase::Hopped { sent_at: now };
}
Phase::Hopped { sent_at } => {
if now - sent_at < 3.0 {
return; // post-teleport settle: let the battlemaster stream in
}
let here = player.pos;
let master = units.iter().find(|(_, net_e, store, tf)| {
net_e.kind == benilla_protocol::EntityKind::Unit
&& store.0.unit_npc_flags() & npc_flags::BATTLEMASTER != 0
&& tf.translation.distance(here) < NEAR_YD
});
if let Some((guid, ..)) = master {
info!(
"PROBE bgqueue: battlemaster {:#x} in range — greeting",
guid.0
);
let _ = net.0.send(ClientCommand::BattlemasterHello { npc: guid.0 });
probe.phase = Phase::Greeted {
master: guid.0,
sent_at: now,
};
} else if now - sent_at > 15.0 {
error!("PROBE bgqueue: FAILURE — no battlemaster streamed in within 15 s");
probe.phase = Phase::Done;
}
}
Phase::Greeted { master, sent_at } => {
// Waiting for the LIST, not for a clock: the guid it carries is the one the join has
// to quote, and its arrival is the proof the level gate was cleared.
if battlefield.battlemaster() == Some(master) {
info!("PROBE bgqueue: list landed — queueing for map {WSG_MAP}");
let _ = net.0.send(ClientCommand::BattlemasterJoin {
battlemaster: master,
map_id: WSG_MAP,
instance_id: 0,
as_group: false,
});
probe.phase = Phase::Joined { sent_at: now };
} else if now - sent_at > 8.0 {
error!(
"PROBE bgqueue: FAILURE — no SMSG_BATTLEFIELD_LIST 8 s after the hello \
(a level under the bracket floor is refused here, silently)"
);
probe.phase = Phase::Done;
}
}
Phase::Joined { sent_at } => {
let queued = queue
.slots()
.iter()
.enumerate()
.find_map(|(i, s)| s.as_ref().map(|(status, _)| (i, status)));
if let Some((slot, status)) = queued {
info!(
"PROBE bgqueue: QUEUED — slot {slot}, map {}, status {}, instance {} \
(the body stays queued; the next plain login is the measurement)",
status.map_id, status.status, status.instance_id,
);
probe.phase = Phase::Done;
} else if now - sent_at > 15.0 {
error!("PROBE bgqueue: FAILURE — no queue slot 15 s after CMSG_BATTLEMASTER_JOIN");
probe.phase = Phase::Done;
}
}
Phase::Done => {}
}
}
@@ -351,7 +351,7 @@ fn charter_tooltip_lines(script: &UiScript, pos: Option<(i64, u32)>) -> Vec<Stri
local a = getglobal("BenillaProbeTipAnchor")
if not a then
a = CreateFrame("Button", "BenillaProbeTipAnchor")
a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
end
GameTooltip:SetOwner(a, "ANCHOR_RIGHT")
GameTooltip:SetBagItem({bag}, {slot})
@@ -32,6 +32,9 @@ impl Plugin for ProbeCrossingPlugin {
#[derive(Resource, Default)]
struct CrossingProbe {
phase: Phase,
/// The one-shot [`BOOTSTRAP_DOCK`] send — so a body that still cannot see a ferry after the
/// hop (wrong continent, a `.go` the server refused) doesn't re-teleport every frame.
bootstrapped: bool,
}
#[derive(Default, PartialEq)]
@@ -54,12 +57,63 @@ enum Phase {
}
/// Yards above the boat's sampled origin the GM drop aims: high enough to clear the deck
/// wherever the model origin sits (waterline or deck), low enough that the post-teleport settle
/// hold (6 s) + the free fall land well inside a ~20 s dock window.
const DROP_HEIGHT: f32 = 10.0;
/// wherever the model origin sits, low enough that the post-teleport settle hold (6 s) + the free
/// fall land well inside the dock window.
///
/// **10 was not high enough, and the failure was silent.** A taxi path's node `z` is the
/// *waterline* for the sea ferries — every node of path 241 carries `z = 0` — while their decks
/// stand ~16 yd above it, so the drop materialised inside the hull and the probe reported
/// "boarding missed the window" forever without ever printing a `transport: board`. The zeppelins
/// hid it: their nodes sit at deck height (71.08), where 10 yd clears fine. Anything on the boat
/// counts as boarding — `owning_transport` walks up to the `Transport` root — so overshooting a
/// cabin roof is harmless and undershooting is not.
const DROP_HEIGHT: f32 = 25.0;
/// Seconds after the deck drop before conceding the boarding failed (settle 6 s + fall + attach,
/// with slack) and re-arming for the next docked window.
const BOARD_DEADLINE: f64 = 15.0;
/// Solid ground on the Booty Bay pier, beside the Ratchet ferry's berth (WoW coords) — where the
/// probe sends itself when no cross-continent transport is in range at all.
///
/// The probe used to *assume* it was standing at a dock: its `Wait` arm only reacts to a transport
/// the server has already put in visibility range, so a probe body parked anywhere else waited
/// forever, printing nothing. That made the instrument unrunnable from a cold login, which is the
/// only way an unattended session ever starts it.
const BOOTSTRAP_DOCK: [f32; 4] = [-14297.2, 531.0, 8.8, 0.0];
/// `WOW_PROBE_DOCK=x,y,z[,map]` — go to *this* dock, before waiting for anything. `map` defaults
/// to 0 and **must be sent**: `.go xyz` without one teleports within the map you are *currently*
/// on, so a probe that has already crossed to Kalimdor and is then aimed at a Booty Bay dock lands
/// at Azeroth's coordinates on Kalimdor — off that map's tile grid, where nothing streams and the
/// loading cover can never clear. This probe did exactly that and hung, which is the bug it was
/// written to chase, arrived at from the other end.
///
/// Without it the probe rides whichever cross-continent ferry the login happens to be standing
/// next to, which is not a choice at all: the 1.12 fleet's seams are not interchangeable (one
/// path crosses mid-cycle, another crosses at the cycle wrap), so "it worked" on the ferry that
/// answered says nothing about the one a report names. Overriding the destination is how a
/// specific seam gets measured.
fn dock_override() -> Option<[f32; 4]> {
let raw = std::env::var("WOW_PROBE_DOCK").ok()?;
let mut it = raw.split(',').map(|p| p.trim().parse::<f32>());
match (it.next(), it.next(), it.next(), it.next(), it.next()) {
(Some(Ok(x)), Some(Ok(y)), Some(Ok(z)), None, _) => Some([x, y, z, 0.0]),
(Some(Ok(x)), Some(Ok(y)), Some(Ok(z)), Some(Ok(m)), None) => Some([x, y, z, m]),
_ => {
warn!("WOW_PROBE_DOCK={raw:?} is not `x,y,z` or `x,y,z,map` — ignored");
None
}
}
}
/// How near [`dock_override`]'s named dock the body must stand before the probe will board
/// anything — wide enough to cover a pier and the ferry moored along it, tight enough that the
/// login spot on another continent can never satisfy it.
const DOCK_ARRIVED_YD: f32 = 200.0;
/// How near the body a docked ferry must be before the probe will drop onto it — see the scan.
/// Generous enough for a long pier, far short of the next dock on the same continent.
const BOARDABLE_YD: f32 = 400.0;
/// Seconds the probe watches for a cross-continent transport before sending itself to
/// [`BOOTSTRAP_DOCK`] — long enough for a login's object stream to deliver one if we are already
/// somewhere it sails from.
const BOOTSTRAP_AFTER: f64 = 20.0;
fn crossing_probe(
time: ProbeClock,
@@ -79,6 +133,58 @@ fn crossing_probe(
let now = time.elapsed_secs_f64();
match probe.phase {
Phase::Wait => {
// Nothing that crosses the sea is in range at all — we are not at a ferry dock. Send
// the body to one (once; `bootstrapped` latches) rather than waiting out the run.
let forced = dock_override();
let adrift = !transports
.iter()
.any(|(_, t, _)| t.touches_map(0) && t.touches_map(1));
// An explicit `WOW_PROBE_DOCK` goes FIRST and waits for nothing: the whole point of
// naming a dock is to ride *that* ferry, and the login spot very often has a different
// one already in range — which the scan below would board within the grace period,
// silently measuring the wrong seam. (It did exactly that: a run aimed at Booty Bay
// rode the Undercity zeppelin instead.) Only the "no ferry anywhere" fallback waits,
// because that one is guessing and should let the object stream finish first.
// **Having SENT the hop is not having ARRIVED.** `.go` is a server round trip; the
// scan below runs again on the very next frame, when the body is still standing where
// it logged in and the old surroundings' ferry is still in range — so it boards that
// one and measures the wrong seam, which is precisely the failure the override exists
// to prevent. (Aimed at Booty Bay, it rode the Menethil–Theramore ferry.) So while a
// dock is named, nothing is boarded until the body is actually standing at it.
if let Some(dock) = forced {
let here = benilla_assets::coords::bevy_to_wow(player.pos);
let (dx, dy) = (here[0] - dock[0], here[1] - dock[1]);
if (dx * dx + dy * dy).sqrt() > DOCK_ARRIVED_YD {
if probe.bootstrapped {
return; // hop sent, still in flight
}
} else {
probe.bootstrapped = true; // arrived; fall through to the scan
}
}
let hop_now = forced.is_some() || (adrift && now > BOOTSTRAP_AFTER);
if hop_now && !probe.bootstrapped {
probe.bootstrapped = true;
let [x, y, z, m] = forced.unwrap_or(BOOTSTRAP_DOCK);
info!(
"PROBE crossing: going to the dock ({x:.1}, {y:.1}, {z:.1}) on map {m} — {}",
if forced.is_some() {
"WOW_PROBE_DOCK"
} else {
"no cross-continent transport in range"
}
);
// The map id is not optional — see [`dock_override`].
let _ = net.0.send(ClientCommand::Chat {
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {}", m as i32),
});
return;
}
if adrift {
return;
}
// A cross-continent transport (the 1.12 fleet crosses EK↔Kalimdor, maps 0↔1),
// currently docked on OUR map: drop onto its deck. `sample.pos` is WoW coords —
// exactly what `.go xyz` takes.
@@ -90,6 +196,17 @@ fn crossing_probe(
if sample.map != map || sample.moving {
continue;
}
// **And it has to be a ferry we are actually standing next to.** A cross-continent
// `.go` leaves the departed surroundings' objects in the entity list until the
// server gets round to removing them, so the scan can otherwise pick a boat
// thousands of yards away and `.go` onto its deck — undoing the hop that just
// placed us, and measuring whichever seam that boat happens to cross. Aimed at
// Booty Bay, this probe boarded the Tirisfal zeppelin twice that way.
let here = benilla_assets::coords::bevy_to_wow(player.pos);
let (dx, dy) = (here[0] - sample.pos[0], here[1] - sample.pos[1]);
if (dx * dx + dy * dy).sqrt() > BOARDABLE_YD {
continue;
}
let [x, y, z] = sample.pos;
info!(
"PROBE crossing: boat {:#x} docked on map {map} at ({x:.1}, {y:.1}, {z:.1}) \
@@ -0,0 +1,435 @@
//! The GameObject-questgiver live probe (`WOW_PROBE_GOQUEST=1`) — the instrument for *"wanted
//! posters and quest objects are never status-queried"*, and for the answer that question turned
//! out to have (decision 1872).
//!
//! A quest-giving **GameObject** — a wanted poster, a half-eaten body, a suspicious barrel — is a
//! questgiver on the wire exactly as a creature is, and the reference client asks about it: the
//! sweep callback tests typemask bit 5 and sends `CMSG_QUESTGIVER_STATUS_QUERY` for any GameObject
//! carrying `GAMEOBJECT_FLAGS` bit 2 whose reaction toward us is `> 1`. benilla asked about no
//! GameObject at all, which is a real wire gap.
//!
//! But **the answer is refused, and nothing is ever drawn**: the reference's handler `0x5dc9f0`
//! resolves the GUID with typemask 8, a GameObject's `0x21` misses bit 3, and the packet dies at
//! `0x5dca2f`. And the real 1.12 service never sent one anyway — zero GameObject GUIDs across the
//! 1292 in the genuine sniff corpus. **vmangos does send one** (`TYPEMASK_CREATURE_OR_GAMEOBJECT`,
//! `QuestHandler.cpp:41`), so on our server the refusal is load-bearing rather than theoretical:
//! without it a wanted poster would wear a gold `!` the reference client leaves bare.
//!
//! That makes this probe's job the three-way distinction a unit test cannot make at the real
//! object: **the query went out**, **the answer came back**, and **we refused it** — where "never
//! asked" and "asked and correctly refused" both read as an empty status map. The refusal readout
//! ([`QuestGiver::refused_for`]) is what separates them, and an arriving status can only exist if
//! our query did. It is keyed by guid on purpose: Goldshire has several quest objects in view and
//! the server answers all of them in one drain, so a single last-writer slot reads as whichever
//! object archetype order happened to put last (it read as a regression exactly once, on a run
//! where nothing about the behaviour had changed).
//!
//! Four readings, in order, and the last two are the controls that make it a regression test:
//!
//! 1. **LOW** — at level [`LOW_LEVEL`], below the quest's own `MinLevel`: the server must answer
//! `1` (`UNAVAILABLE`, the grey `!` a *creature* would wear) and we must refuse it.
//! 2. **HIGH** — after a ding past `MinLevel`: the answer must change to `5` (`AVAILABLE`). A probe
//! that only ever saw one value could not tell a live query from a single stale packet.
//! 3. **NO STATUS** — `QuestGiver::status()` for the poster stays `None` throughout, sampled every
//! frame rather than at the windows. The marker layer and the minimap dot both draw from exactly
//! that map, so an empty entry *is* "no `!`, no dot".
//! 4. **THE UNIT CONTROL** — a creature questgiver in the same scene still gets and keeps its own
//! status. This is the half that must not change, and it is what would catch a refusal written
//! one bit too wide.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
//! WOW_PROBE_GOQUEST=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
//! "The local vmangos server"). `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]` aims it elsewhere; the
//! default is the **Goldshire wanted poster** — `gameobject.guid` 26843, template 68
//! `Wanted Poster`, `type` 2 `GAMEOBJECT_TYPE_QUESTGIVER`, `flags` 4 `INTERACT_COND`, offering
//! quest 176 `Wanted: "Hogger"` (`MinLevel` 5, `QuestLevel` 11). It is a single **unpooled** spawn
//! with a 900 s respawn, so it is there every run — unlike the chest probe's first target, which
//! was a spawn-pool member and read "missing" most of the time (decision 1471's trap). Goldshire
//! also supplies the unit control (Marshal Dughan and the innkeeper are both questgiver-flagged),
//! which is why both halves can be read at one spot.
//!
//! Each window forces its own sweep, because that is the *only* way a GameObject GUID reaches the
//! wire: it sets the level (the reference's own `UNIT_FIELD_LEVEL` watch, and what makes the answer
//! change) and then bumps the packet epoch, so the window sweeps even if the character already
//! happened to be standing at the level the window wants. The starting level is restored on exit.
//!
//! **One trap the scan line will show you:** every probe character is permanently in GM mode
//! (`characters.extra_flags` bit `0x1`), and vmangos ORs `target->IsGameMaster()` into a
//! GameObject's `IsActivateToQuest` (`Object.cpp`) — so `GAMEOBJECT_DYN_FLAGS` reads `0x1` on
//! every quest object regardless of whether the character could actually take the quest. The
//! *dialog status* is computed without any GM term, so the windows below are unaffected; but do
//! not read the dyn-flag as "this quest is available to me", and do not gate a query on it.
//!
//! Grep `PROBE_GOQUEST:` for the verdict; the probe self-exits when it lands.
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use super::probes::ProbeClock;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::ui_quest::QuestGiver;
/// The probe's default object: the `Wanted Poster` outside the Goldshire inn (`gameobject` 26843,
/// template 68, quest 176). `[x, y, z, map]`.
const POSTER_AT: [f32; 4] = [-9668.23, 683.39, 36.33, 0.0];
/// `GAMEOBJECT_TYPE_QUESTGIVER` — the strategy type a wanted poster carries.
const GO_TYPE_QUESTGIVER: i32 = 2;
/// `GAMEOBJECT_FLAGS` bit 2 — the reference's whole GameObject query gate (`0x5eb0f2 shr eax,0x2`).
const GO_FLAG_INTERACT_COND: u32 = 0x4;
/// `UNIT_NPC_FLAGS` questgiver bit — how the unit control names itself.
const NPC_FLAG_QUESTGIVER: u32 = 0x2;
/// The two levels the two windows are read at, chosen against quest 176's own `MinLevel` 5 and
/// `QuestLevel` 11: below it the server answers `UNAVAILABLE`, above it `AVAILABLE` (and not
/// `CHAT`, which would need `level > QuestLevel + Quests.LowLevelHideDiff`).
const LOW_LEVEL: u32 = 2;
const HIGH_LEVEL: u32 = 8;
/// `DialogStatus` ids the verdict is written in (wow-re `questgiver-marker.md` §Q1's own map).
const STATUS_UNAVAILABLE: u32 = 1;
const STATUS_AVAILABLE: u32 = 5;
const SETTLE_SECS: f64 = 5.0;
const SCAN_TIMEOUT_SECS: f64 = 25.0;
/// How long a window waits for the refused answer. The query goes out on the sweep the window
/// itself forces; nothing arriving in this long means it was never sent.
const STATUS_TIMEOUT_SECS: f64 = 15.0;
/// How long a `.levelup` is given to come back down the wire as a `UNIT_FIELD_LEVEL` change.
const LEVEL_TIMEOUT_SECS: f64 = 15.0;
/// Scan radius around the landing spot, in yards.
const SCAN_RANGE: f32 = 40.0;
pub(crate) struct ProbeGoQuestPlugin;
impl Plugin for ProbeGoQuestPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GoQuestProbe>()
.add_systems(Update, goquest_probe);
}
}
#[derive(Resource, Default)]
struct GoQuestProbe {
phase: Phase,
/// The poster's guid, once the scan finds it.
poster: Option<u64>,
/// The unit control's guid — a questgiver-flagged creature in the same scene.
control: Option<u64>,
/// The level the character was at when the probe started — restored before it exits.
start_level: Option<u32>,
/// The refused status read in each window.
low: Option<u32>,
high: Option<u32>,
/// Whether the poster ever acquired a stored status (it must not) and the control's own stored
/// status (it must have one).
poster_ever_stored: bool,
control_stored: Option<u32>,
fails: u32,
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Wait,
/// `.go` issued; letting the world stream the poster in.
Settling {
sent_at: f64,
},
/// Levelling into a window's level, then sweeping.
Level {
to: u32,
since: f64,
},
/// Waiting for the refused answer at the window's level.
Read {
level: u32,
since: f64,
},
/// Levelling back to where the character started.
Restore {
since: f64,
},
Done,
}
/// Where the probe is aimed: `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]`, else [`POSTER_AT`]. Anything
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
fn target() -> [f32; 4] {
let Ok(raw) = std::env::var("WOW_PROBE_GOQUEST") else {
return POSTER_AT;
};
let parts: Vec<f32> = raw
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
match parts.len() {
3 => [parts[0], parts[1], parts[2], POSTER_AT[3]],
4 => [parts[0], parts[1], parts[2], parts[3]],
_ => POSTER_AT,
}
}
/// `.levelup <delta>` on the probe's own character. Nothing is ever selected by this probe, so the
/// command's creature branch (`GetSelectedCreature`) can't fire.
fn level_to(net: &NetCommands, from: u32, to: u32) {
let delta = i64::from(to) - i64::from(from);
if delta == 0 {
return;
}
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".levelup {delta}"),
});
}
/// One window's verdict line.
fn report(label: &str, expect: u32, seen: Option<u32>) -> u32 {
match seen {
Some(s) if s == expect => {
info!("PROBE_GOQUEST: {label:<4} PASS — the server answered {s}, and we refused it");
0
}
Some(s) => {
error!("PROBE_GOQUEST: {label:<4} FAIL — refused status {s}, expected {expect}");
1
}
None => {
error!(
"PROBE_GOQUEST: {label:<4} FAIL — no status ever arrived for the object (expected \
{expect}). vmangos answers every status query it can resolve, so nothing arriving \
means the query was never SENT — the sweep's GameObject leg is not firing"
);
1
}
}
}
#[allow(clippy::too_many_arguments)]
fn goquest_probe(
time: ProbeClock,
mut probe: ResMut<GoQuestProbe>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
player: Res<Player>,
objects: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
mut quest: ResMut<QuestGiver>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
let Ok(me) = self_store.single() else {
return; // not in-world yet
};
let Some(level) = me.0.unit_level() else {
return; // our own descriptor hasn't landed
};
let now = time.elapsed_secs_f64();
// Sampled every frame, not just at the windows: a status that flashed on for one frame and was
// pruned would still have been a rendered `!`, and the claim is that none is ever stored.
if probe.poster.is_some_and(|p| quest.status(p).is_some()) {
probe.poster_ever_stored = true;
}
if let Some(s) = probe.control.and_then(|c| quest.status(c)) {
probe.control_stored = Some(s);
}
match probe.phase {
Phase::Wait => {
let [x, y, z, map] = target();
probe.start_level = Some(level);
info!(
"PROBE_GOQUEST: heading to the quest object ({x} {y} {z} map {map}) — \
GameObject type {GO_TYPE_QUESTGIVER}, starting level {level}"
);
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {map}"),
});
probe.phase = Phase::Settling { sent_at: now };
}
Phase::Settling { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let here = player.pos;
let in_range = |tf: &Transform| tf.translation.distance(here) < SCAN_RANGE;
let poster = objects.iter().find(|(_, net_e, store, tf)| {
net_e.kind == EntityKind::GameObject
&& store.0.gameobject_type_id() == GO_TYPE_QUESTGIVER
&& in_range(tf)
});
let control = objects.iter().find(|(_, net_e, store, tf)| {
net_e.kind == EntityKind::Unit
&& store.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER != 0
&& in_range(tf)
});
let Some((guid, _, store, _)) = poster else {
if now - sent_at > SCAN_TIMEOUT_SECS {
error!(
"PROBE_GOQUEST: FAIL — no type-{GO_TYPE_QUESTGIVER} GameObject within \
{SCAN_RANGE} yd in {SCAN_TIMEOUT_SECS}s. This is NOT a passing run"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
return;
};
let flags = store.0.gameobject_flags();
info!(
"PROBE_GOQUEST: quest object {:#x} in range — GAMEOBJECT_FLAGS {flags:#x} \
(INTERACT_COND {}), GAMEOBJECT_DYN_FLAGS {:#x}",
guid.0,
flags & GO_FLAG_INTERACT_COND != 0,
store.0.gameobject_dynamic_flags(),
);
if flags & GO_FLAG_INTERACT_COND == 0 {
error!(
"PROBE_GOQUEST: FAIL — this object does not carry GAMEOBJECT_FLAGS bit 2, so \
the reference would never query it either. Aim the probe at a quest object"
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
probe.poster = Some(guid.0);
match control {
Some((g, ..)) => {
info!(
"PROBE_GOQUEST: unit control — questgiver creature {:#x}",
g.0
);
probe.control = Some(g.0);
}
None => warn!(
"PROBE_GOQUEST: no questgiver-flagged creature within {SCAN_RANGE} yd — the \
unit control cannot be read at this spot"
),
}
probe.phase = Phase::Level {
to: LOW_LEVEL,
since: now,
};
level_to(&net, level, LOW_LEVEL);
}
Phase::Level { to, since } => {
if level == to {
// Force the sweep this window depends on. A GameObject GUID reaches the wire ONLY
// from a sweep (wow-re §W14.8), and the level change above is one — but it is a
// no-op when the character already stood at this level, so the epoch bump makes
// the window's sweep unconditional.
quest.bump_reask();
info!("PROBE_GOQUEST: at level {to}, swept — reading the window");
probe.phase = Phase::Read {
level: to,
since: now,
};
} else if now - since > LEVEL_TIMEOUT_SECS {
error!("PROBE_GOQUEST: FAIL — still level {level} after a `.levelup` to {to}");
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Read { level: at, since } => {
let count = quest.refused_count();
let mine = probe.poster.and_then(|p| quest.refused_for(p));
// The HIGH window must see a *different* answer than LOW, or one stale refusal would
// satisfy both.
let settled = if at == LOW_LEVEL {
mine
} else {
mine.filter(|s| Some(*s) != probe.low)
};
if settled.is_none() && now - since <= STATUS_TIMEOUT_SECS {
return;
}
if at == LOW_LEVEL {
probe.low = settled;
info!(
"PROBE_GOQUEST: LOW refused {settled:?} (refusals so far: {count}) — dinging \
to level {HIGH_LEVEL}"
);
probe.phase = Phase::Level {
to: HIGH_LEVEL,
since: now,
};
level_to(&net, level, HIGH_LEVEL);
} else {
probe.high = settled;
let back = probe.start_level.unwrap_or(level);
info!(
"PROBE_GOQUEST: HIGH refused {settled:?} (refusals so far: {count}) — \
restoring level {back}"
);
level_to(&net, level, back);
probe.phase = Phase::Restore { since: now };
}
}
Phase::Restore { since } => {
let back = probe.start_level.unwrap_or(level);
if level == back {
probe.phase = Phase::Done;
} else if now - since > LEVEL_TIMEOUT_SECS {
warn!("PROBE_GOQUEST: left the character at level {level}, not {back}");
probe.phase = Phase::Done;
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
let mut fails = probe.fails;
fails += report("LOW", STATUS_UNAVAILABLE, probe.low);
fails += report("HIGH", STATUS_AVAILABLE, probe.high);
if probe.poster_ever_stored {
error!(
"PROBE_GOQUEST: STORE FAIL — a status was stored for the GameObject. The \
marker layer and the minimap dot both read that map, so this is a `!` the \
reference client does not draw"
);
fails += 1;
} else {
info!(
"PROBE_GOQUEST: STORE PASS — no status ever stored for the GameObject, so no \
marker and no minimap dot"
);
}
match (probe.control, probe.control_stored) {
(Some(g), Some(s)) => info!(
"PROBE_GOQUEST: CTRL PASS — the questgiver creature {g:#x} still has its own \
status ({s})"
),
(Some(g), None) => {
error!(
"PROBE_GOQUEST: CTRL FAIL — the questgiver creature {g:#x} never got a \
status. The refusal is one bit too wide and units lost theirs"
);
fails += 1;
}
(None, _) => warn!("PROBE_GOQUEST: CTRL SKIPPED — no creature questgiver in range"),
}
info!(
"PROBE_GOQUEST: DONE object={:#x} low={:?} high={:?} refusals={} fail={fails}",
probe.poster.unwrap_or_default(),
probe.low,
probe.high,
quest.refused_count(),
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_GOQUEST: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
@@ -0,0 +1,357 @@
//! `WOW_PROBE_MODEL_CAMERA=1` — the live probe for the `<Model>` widget's **perspective leg**
//! (decision 2027): a plain `CreateFrame("Model")` pointed at a camera-bearing file, framed by the
//! file's own camera through a camera of its own into its cell of the tile atlas.
//!
//! ## What it asks, and why it is a probe rather than a test
//!
//! The unit tests in `crate::ui_models` pin the leg's arithmetic — the client's diagonal-FOV
//! matrix against wow-re's worked numbers, and the three cancellations. What they cannot reach is
//! the half that only exists at run time: does the engine resolve the camera at all, does the
//! renderer find the record, does a camera get spawned, aimed, and pointed at a real cell of a
//! real atlas. So this probe drives the **whole live pipeline** from Lua and reads the result off
//! the entities the renderer actually built.
//!
//! The measurement is numeric, not visual (method.md's rule: a capture can confirm an existence
//! fact, it cannot measure one). Each leg projects three model-space probe points through the
//! camera and the root the renderer placed this frame, and compares the resulting NDC against the
//! baseline's:
//!
//! - `scale` — `SetModelScale(3)` must not move a pixel. The authored eye is carried through the
//! model's root transform, so the camera scales with the model.
//! - `position` — `SetPosition(0.4, −0.3, 0.9)` must not move a pixel, for the same reason.
//! - `facing` — `SetFacing(1.0)` must not move a pixel **either**. That one is the finding this
//! work commissioned (wow-re `modelframe-facing-cancels.md`): the up vector `0x7ac640` builds is
//! model-space `+Z` at `roll = 0`, which is the axis the facing turns about, so eye, target,
//! geometry and up all turn together. `modelframe-render-law.md` §2's "only `SetFacing` shows"
//! is scoped to `<PlayerModel>`'s frozen camera.
//! - `ortho` — the control that must NOT hold: `SetCamera(9)` is past the file's camera count, so
//! the widget installs the NULL camera and falls to the orthographic leg, where a facing is a
//! plain roll in the screen plane. A probe whose "identical" legs all passed because nothing was
//! drawing would fail here.
//!
//! Three screenshots ride along — the baseline, the scaled/offset one and the turned one — as the
//! existence evidence and for the director's own eye. They are the whole window, so they are not
//! compared: the world behind the pane moves.
//!
//! ## The run recipe
//! ```text
//! WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive WOW_UNATTENDED=1 WOW_NOSOUND=1 \
//! WOW_PROBE_MODEL_CAMERA=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — method.md "The local vmangos server"). `WOW_TILE_TRACE=1`
//! alongside it prints the leg, the record, the eye and the matrix per pane per frame.
use bevy::camera::visibility::RenderLayers;
use bevy::prelude::*;
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::net::SelfPlayer;
use crate::ui_models::{TilePerspectiveCamera, TileRoot, UiModelTiles};
/// The file the probe frames. `Creature\Wolf\Wolf.m2` carries **two** cameras
/// (`benilla-extract m2cam`: index 0 type 0 at eye `(1.6927, 0.7238, 0.8837)` fov `0.95002`,
/// index 1 type 1 at eye `(5.5556, 0, 1.8056)` fov `0.67620`), so it exercises the raw-index
/// selection AND has an index past the count for the orthographic control.
const FILE: &str = r"Creature\\Wolf\\Wolf.mdx";
/// The pane's size in FrameXML units — the pet pane's own `318×224`, whose aspect is wow-re's
/// worked example (`camera-law.md` §12.1: `θ = 0.287938 · fov`).
const PANE_W: f32 = 318.0;
const PANE_H: f32 = 224.0;
/// Frames to let a change settle before the numbers are read: the extract republishes the request
/// on the next conversion and the renderer aims the camera the frame after that.
const SETTLE_FRAMES: u32 = 8;
/// NDC agreement the cancellation legs must hold to. Generous against f32 round-off through two
/// 4×4 composes; a leg that failed to cancel misses by tens of percent, not by a thousandth.
const NDC_EPS: f32 = 1.0e-3;
/// The Lua that builds the pane. Anonymous is the corpus shape (pfUI's autocast shine), but the
/// probe needs a handle on it, so it is global.
const BUILD: &str = r#"
ProbeModelCam = CreateFrame("Model", "ProbeModelCam", UIParent)
ProbeModelCam:SetWidth(318)
ProbeModelCam:SetHeight(224)
ProbeModelCam:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
ProbeModelCam:SetModel("Creature\\Wolf\\Wolf.mdx")
ProbeModelCam:SetCamera(0)
ProbeModelCam:SetLight(1, 0, 0, -0.707, -0.707, 0.7, 1.0, 1.0, 1.0, 0.8, 1.0, 1.0, 0.8)
ProbeModelCam:Show()
"#;
pub(crate) struct ProbeModelCameraPlugin;
impl Plugin for ProbeModelCameraPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ModelCameraProbe>()
.add_systems(Update, model_camera_probe);
}
}
/// One leg's reading: the NDC of the three probe points, and the label to report it under.
#[derive(Clone, Copy, Default)]
struct Reading([Vec2; 3]);
#[derive(Resource, Default)]
struct ModelCameraProbe {
phase: Phase,
baseline: Option<Reading>,
passes: u32,
fails: u32,
skips: u32,
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Start,
/// Waiting for the pane's tile to be packed and its camera aimed.
Await {
since: f64,
},
/// A change is in flight; read it in `at`.
Settle {
frames: u32,
leg: Leg,
},
Done,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Leg {
Baseline,
Scale,
Position,
Facing,
Ortho,
}
impl Leg {
/// The Lua that puts the pane INTO this leg — run when the leg is entered, before its reading.
fn setup(self) -> &'static str {
match self {
Leg::Baseline => "",
Leg::Scale => "ProbeModelCam:SetModelScale(3)",
Leg::Position => {
"ProbeModelCam:SetModelScale(1) ProbeModelCam:SetPosition(0.4, -0.3, 0.9)"
}
Leg::Facing => "ProbeModelCam:SetPosition(0, 0, 0) ProbeModelCam:SetFacing(1.0)",
// The control: index 9 is past Wolf's two cameras, so the widget installs the NULL
// camera and the pane drops to the orthographic leg. Its perspective camera goes
// inactive, which is what the reading detects.
Leg::Ortho => "ProbeModelCam:SetCamera(9)",
}
}
fn next(self) -> Option<Leg> {
match self {
Leg::Baseline => Some(Leg::Scale),
Leg::Scale => Some(Leg::Position),
Leg::Position => Some(Leg::Facing),
Leg::Facing => Some(Leg::Ortho),
Leg::Ortho => None,
}
}
}
/// The three model-space points the legs are compared on — a wolf-sized spread, in Bevy space
/// (the frame the tile root and the camera both work in).
fn probe_points() -> [Vec3; 3] {
[
benilla_assets::coords::wow_to_bevy([0.0, 0.0, 0.8]),
benilla_assets::coords::wow_to_bevy([0.6, -0.3, 0.4]),
benilla_assets::coords::wow_to_bevy([-0.4, 0.5, 1.1]),
]
}
/// Project the three points through whatever the renderer built this frame — the ACTIVE
/// perspective camera and the tile root under its layer. `None` when no perspective camera is
/// aimed (which is the orthographic leg, and the control's expected answer).
fn read_leg(
cams: &Query<
(&Camera, &GlobalTransform, &Projection, &RenderLayers),
With<TilePerspectiveCamera>,
>,
roots: &Query<(&GlobalTransform, &RenderLayers), With<TileRoot>>,
) -> Option<Reading> {
let (_, cam_tf, proj, layers) = cams.iter().find(|(c, _, _, _)| c.is_active)?;
// The pane's root is the one on the camera's OWN layer — a perspective camera sees exactly one
// tile by construction, and the interface has other tiles up (the minimap ping, the map arrow)
// whose roots would otherwise be picked at random.
let (root, _) = roots.iter().find(|(_, l)| *l == layers)?;
let clip = proj.get_clip_from_view() * cam_tf.to_matrix().inverse() * root.to_matrix();
let mut out = [Vec2::ZERO; 3];
for (o, p) in out.iter_mut().zip(probe_points()) {
let c = clip * p.extend(1.0);
*o = Vec2::new(c.x / c.w, c.y / c.w);
}
Some(Reading(out))
}
fn shoot(commands: &mut Commands, name: &str) {
// Every file we write goes through `local_state` — never into the WoW install, never a
// platform config dir (the contract's one-folder rule).
let Some(path) = crate::local_state::home().map(|d| d.join(format!("{name}.png"))) else {
warn!("PROBE_MODEL_CAMERA: no local-state folder — skipping the {name} shot");
return;
};
info!("PROBE_MODEL_CAMERA: shot -> {}", path.display());
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(path));
}
#[allow(clippy::too_many_arguments)]
fn model_camera_probe(
mut commands: Commands,
time: ProbeClock,
mut probe: ResMut<ModelCameraProbe>,
script: Option<NonSendMut<UiScript>>,
tiles: Res<UiModelTiles>,
self_player: Query<(), With<SelfPlayer>>,
cams: Query<
(&Camera, &GlobalTransform, &Projection, &RenderLayers),
With<TilePerspectiveCamera>,
>,
roots: Query<(&GlobalTransform, &RenderLayers), With<TileRoot>>,
mut exit: MessageWriter<AppExit>,
) {
if self_player.is_empty() {
return; // not in-world yet
}
let Some(script) = script else { return };
let now = time.elapsed_secs_f64();
match probe.phase {
Phase::Start => {
info!("PROBE_MODEL_CAMERA: building a {PANE_W}x{PANE_H} <Model> on {FILE}, camera 0");
if let Err(e) = script.run(BUILD) {
error!("PROBE_MODEL_CAMERA: FAIL building the pane: {e}");
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
probe.phase = Phase::Await { since: now };
}
Phase::Await { since } => {
if !tiles.cells.is_empty() && read_leg(&cams, &roots).is_some() {
let cell = tiles.cells.values().next().copied();
info!("PROBE_MODEL_CAMERA: tile up, cell {cell:?} — the perspective leg is live");
probe.passes += 1;
probe.phase = Phase::Settle {
frames: 0,
leg: Leg::Baseline,
};
} else if now - since > 30.0 {
// The asset never landed, or the pane never reached the paint list. An
// environmental miss, not a wrong answer.
error!(
"PROBE_MODEL_CAMERA: SKIP — no tile after 30 s (cells={}, camera aimed={})",
tiles.cells.len(),
read_leg(&cams, &roots).is_some()
);
probe.skips += 1;
probe.phase = Phase::Done;
}
}
Phase::Settle { frames, leg } => {
if frames < SETTLE_FRAMES {
probe.phase = Phase::Settle {
frames: frames + 1,
leg,
};
return;
}
let reading = read_leg(&cams, &roots);
match (leg, reading) {
(Leg::Baseline, Some(r)) => {
info!("PROBE_MODEL_CAMERA: baseline ndc {:?}", r.0);
probe.baseline = Some(r);
shoot(&mut commands, "probe-model-camera-a-baseline");
}
(Leg::Ortho, None) => {
info!(
"PROBE_MODEL_CAMERA: ortho PASS — SetCamera(9) is past the file's two \
cameras, so the NULL camera installed and the perspective camera parked"
);
probe.passes += 1;
}
(Leg::Ortho, Some(_)) => {
error!(
"PROBE_MODEL_CAMERA: ortho FAIL — an out-of-range SetCamera left the \
perspective leg live; the bounds check is not being applied"
);
probe.fails += 1;
}
(_, None) => {
error!("PROBE_MODEL_CAMERA: {leg:?} FAIL — the perspective camera went away");
probe.fails += 1;
}
(_, Some(r)) => {
let base = probe.baseline.unwrap_or_default();
let worst = base
.0
.iter()
.zip(r.0)
.map(|(a, b)| (a.x - b.x).abs().max((a.y - b.y).abs()))
.fold(0.0f32, f32::max);
if worst <= NDC_EPS {
info!(
"PROBE_MODEL_CAMERA: {leg:?} PASS — cancels, worst NDC delta {worst:.6}"
);
probe.passes += 1;
} else {
error!(
"PROBE_MODEL_CAMERA: {leg:?} FAIL — worst NDC delta {worst:.6} > \
{NDC_EPS}; base {:?} got {:?}",
base.0, r.0
);
probe.fails += 1;
}
if leg == Leg::Scale {
shoot(&mut commands, "probe-model-camera-b-scale-and-position");
}
if leg == Leg::Facing {
shoot(&mut commands, "probe-model-camera-c-facing");
}
}
}
match leg.next() {
Some(next) => {
if let Err(e) = script.run(next.setup()) {
error!("PROBE_MODEL_CAMERA: FAIL setting up {next:?}: {e}");
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
probe.phase = Phase::Settle {
frames: 0,
leg: next,
};
}
None => probe.phase = Phase::Done,
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
info!(
"PROBE_MODEL_CAMERA: DONE pass={} fail={} skip={}",
probe.passes, probe.fails, probe.skips
);
exit.write(AppExit::Success);
// The polite `AppExit` plus a hard backstop, the harness's standard pair
// (`ProbeExitPlugin::fire_probe_exit`): a net or winit teardown hang must not leave a
// zombie client holding the probe account.
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_MODEL_CAMERA: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
@@ -0,0 +1,913 @@
//! The **NPC-service ladder** live probe (`WOW_PROBE_SERVICE=1`) — decision 1861's end-to-end
//! instrument, and the answer to "does right-clicking a trainer or a quest giver really open their
//! window instead of a gossip menu?".
//!
//! 1861 replaced a cursor-kind dispatch with the reference's own first-match-wins walk over
//! `UNIT_NPC_FLAGS` ([`crate::target::click::service_arm`]; wow-re
//! `object-layer/scratch/interact-dead-fork-and-npc-service-ladder.md` §C). **Bit 0 (GOSSIP) is
//! tested first**, so the flag — not the profession — decides: a trainer or questgiver that also
//! carries GOSSIP still opens a gossip menu, and only a flagless one opens its own window. That
//! precedence is the whole question, and it is invisible to a unit test: the bits come off the
//! wire, and what appears on screen is the *server's* answer to the opcode the ladder picked.
//!
//! So the probe walks four real Northshire/Stormwind NPCs, one per interesting flag shape, and for
//! each one reports the wire's `UNIT_NPC_FLAGS`, the arm the shipped ladder takes, the opcode it
//! sends, and which window actually opened:
//!
//! | NPC | `npc_flags` | arm | window |
//! |---|---|---|---|
//! | Marshal McBride (197) | `0x03` GOSSIP\|QUESTGIVER | Gossip | gossip menu |
//! | Llane Beshere (911) | `0x13` GOSSIP\|QUESTGIVER\|TRAINER | Gossip | gossip menu |
//! | *any pure questgiver with an offer* | `0x02`, GOSSIP clear | Questgiver | quest frame |
//! | Alma Jainrose (812) | `0x10` TRAINER only | Trainer | trainer frame |
//!
//! The flag column is live-DB verified this session (`vmangos-deploy` → `mangos`,
//! `SELECT entry, name, npc_flags FROM creature_template`), and reported again from the wire on
//! every run — a template edit shows up as a note beside the reading rather than as a mystery FAIL.
//!
//! **The ladder is called, never re-implemented.** The probe reads the same two inputs the click
//! reads (`ObjectStore::unit_npc_flags` and [`QuestGiver::status`]) and calls the shipped
//! `service_arm`/`service_action`; only the mouse hit-test and the cursor's range gray sit outside
//! it. A private copy of the bit table is exactly how a probe goes quietly stale (the B249 icon
//! map), so there isn't one.
//!
//! **The pure-questgiver row is found by predicate, not by entry.** Whether Deputy Willem has
//! anything for *this* probe character depends on what it has already turned in, and the bit-1 arm
//! is gated on that status (`[unit+0xcb8] ∉ {0,1}`). So that leg hops into Northshire and takes the
//! first streamed unit that is QUESTGIVER, not GOSSIP, and actually has an offer — reporting which
//! one it picked. If the character has cleared the whole valley it SKIPs and says so.
//!
//! One `PROBE_SERVICE: <leg> PASS/FAIL/SKIP <detail>` line per leg, then a final
//! `PROBE_SERVICE: DONE pass=<n> fail=<m> skip=<k>`. A wrong arm or a wrong window is a FAIL; an
//! environmental problem (the `.go` refused, the NPC never streamed, no offer left in the valley)
//! is a SKIP with the reason.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_DATA=WoW/Data WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
//! WOW_UNATTENDED=1 WOW_PROBE_SERVICE=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — method.md "The local vmangos server"). Non-combat, GM mode
//! left as found, nothing bought and nothing turned in: every leg opens a window and closes it
//! client-side, which is what the reference's own close does (`ui_gossip`: there is no
//! `CMSG_GOSSIP_CLOSE` in 1.12).
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use super::probes::ProbeClock;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::target::click::{service_action, service_arm, ServiceAction, ServiceArm};
use crate::target::cursor_mode::npc_flags as f;
use crate::ui_gossip::GossipState;
use benilla_ui::script::UiScript;
use crate::ui_merchant::MerchantOpen;
use crate::ui_quest::QuestGiver;
use crate::ui_session::InteractNpc;
use crate::ui_trainer::TrainerOpen;
/// How wide to look around a `.go` landing for the leg's NPC — **and no wider than the reference's
/// own service reach**, because the click cannot act past it either: beyond
/// [`crate::target::SERVICE_RANGE_SQ`] (5.5556 yd, `0xc4c28c`/`0xb4b32c`) the cursor goes gray, the
/// click sends nothing, and vmangos's `GetNPCIfCanInteractWith` refuses the opcode silently.
///
/// This constant is the reason the questgiver leg first read as a client defect: its hop point was
/// an invented Northshire "hub" ~29 yd from Deputy Willem, so the probe called a ladder the click
/// would never have reached, the server dropped the packet on its own distance check, and the
/// timeout printed a FAIL about the client (method.md §6 — prove the run before reading the
/// result). Every hop point is now an NPC's own spawn, and the scan re-checks the reach.
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
/// Let the hop land and the tile stream before the first scan.
const SETTLE_SECS: f64 = 3.0;
/// How long a leg waits for its NPC to stream in before calling the hop environmental.
const SCAN_TIMEOUT_SECS: f64 = 20.0;
/// How long a leg waits for its window after the opcode goes out. Generous for the same reason the
/// binder probe's is: the observer is starved during a terrain load, never the wire.
const WINDOW_TIMEOUT_SECS: f64 = 20.0;
/// How long the re-click gate's latch may lag the window it belongs to.
///
/// It no longer lags by construction: `feed_interact_npc` is seated after the net apply since
/// decision 2022, so the token arms on the very frame the window opens. It USED to be allowed to
/// lag a frame (`ui_session` was "deliberately unordered" against the apply, on decision 1741's
/// reading that a window's first frame is invisible) — and "one frame" and "never" are the same
/// reading if you sample once, which is exactly what the first run of this assert did: it failed
/// the quest and trainer legs, whose windows open on the packet, and passed the gossip legs only
/// because the gossip frame holds shut for several frames waiting on its greeting query (B292).
/// The poll stays for the other reason a single sample lies — a starved observer during a terrain
/// load — and prints how long the latch actually took.
const GATE_TIMEOUT_SECS: f64 = 3.0;
/// **The vendor fork's leg** (decision 1914) — Brother Danil, `creature_template.entry = 152`,
/// `npc_flags = 4` (VENDOR and nothing else, so the ladder cannot reach him by any other arm),
/// spawned in Northshire at his own `creature.position_*` (live-DB verified this session).
const VENDOR_ENTRY: u32 = 152;
const VENDOR_AT: [f32; 3] = [-8901.59, -112.716, 82.0314];
const VENDOR_MAP: u32 = 0;
/// Tough Jerky — a 1-copper vendor trash item, `.additem`'d so the sell leg has something of its
/// own to sell and never touches whatever the probe character was already carrying.
const SELL_ITEM_ENTRY: u32 = 117;
/// How long to wait for `.additem` to land in a bag, and for the sold item to leave it.
const BAG_TIMEOUT_SECS: f64 = 20.0;
/// Which NPC a leg is looking for.
enum Ident {
/// A specific `creature_template.entry` — the identity check that cannot be confused by a
/// neighbour who happens to share a flag.
Entry(u32),
/// The bit-1 arm's own shape: QUESTGIVER set, GOSSIP clear, and an offer live for THIS
/// character (`SMSG_QUESTGIVER_STATUS` ∉ {0,1}) — see the module doc.
PureQuestgiverWithOffer,
}
/// Which window the leg's arm must put on screen.
#[derive(Clone, Copy, PartialEq, Debug)]
enum Window {
Gossip,
Quest,
Trainer,
}
/// One leg: hop here, find this NPC, run the ladder on it, expect this arm and this window.
struct Leg {
/// What the log calls it.
name: &'static str,
ident: Ident,
map: u32,
/// The spawn points to try, in order — each an NPC's own `creature.position_*`, so the landing
/// is inside the service reach. A leg with several is one whose subject depends on what this
/// character has already turned in (the questgiver leg); it moves to the next candidate when
/// the one it hopped to has nothing to offer.
at: &'static [[f32; 3]],
/// `creature_template.npc_flags` as the world DB held it when this probe was written — printed
/// beside the wire's value so a template edit reads as a note rather than a mystery.
db_flags: u32,
want_arm: ServiceArm,
want_window: Window,
}
/// The four flag shapes worth a live reading (module doc's table). Northshire first — three of the
/// four legs share one hop's worth of terrain — then the Stormwind first-aid trainer.
const LEGS: &[Leg] = &[
Leg {
name: "Marshal McBride — GOSSIP|QUESTGIVER",
ident: Ident::Entry(197),
map: 0,
at: &[[-8902.59, -162.606, 82.0223]],
db_flags: 0x3,
want_arm: ServiceArm::Gossip,
want_window: Window::Gossip,
},
Leg {
name: "Llane Beshere — GOSSIP|QUESTGIVER|TRAINER",
ident: Ident::Entry(911),
map: 0,
at: &[[-8918.36, -208.411, 82.3088]],
db_flags: 0x13,
want_arm: ServiceArm::Gossip,
want_window: Window::Gossip,
},
Leg {
name: "a pure questgiver with an offer — QUESTGIVER, no GOSSIP",
ident: Ident::PureQuestgiverWithOffer,
map: 0,
// Deputy Willem (823), Eagan Peltskinner (196), Falkhaan Isenstrider (6774) — the three
// pure questgivers of Northshire, each at its own spawn (live-DB verified this session).
at: &[
[-8933.54, -136.523, 83.4466],
[-8869.22, -163.237, 80.9719],
[-9044.56, -45.9817, 88.4193],
],
db_flags: 0x2,
want_arm: ServiceArm::Questgiver,
want_window: Window::Quest,
},
Leg {
name: "Alma Jainrose — TRAINER, no GOSSIP",
ident: Ident::Entry(812),
map: 0,
at: &[[-9237.78, -2041.65, 78.1678]],
db_flags: 0x10,
want_arm: ServiceArm::Trainer,
want_window: Window::Trainer,
},
];
pub(crate) struct ProbeServicePlugin;
impl Plugin for ProbeServicePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ServiceProbe>()
.add_systems(Update, service_probe);
}
}
#[derive(Resource, Default)]
struct ServiceProbe {
phase: Phase,
passes: u32,
fails: u32,
skips: u32,
/// How many frames the current scan actually got to poll in. A starved observer and a missing
/// NPC look identical in a SKIP line otherwise — and on a loaded machine the first is far more
/// likely: at load 61 this probe logged `frame hitch: ~1010 ms` on repeat (the ~1 fps regime
/// decisions 0713/0777/1355 name) and its 20-second scan window bought about twenty samples.
/// `leg.sh` answers the same problem with a load guard (1157); a probe that does one thing and
/// exits is better served by reporting what it actually got.
polls: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
/// Not in-world yet, or between legs: `.go` for leg `i` still to send.
#[default]
Start,
Hop {
i: usize,
/// Which of the leg's spawn points we hopped to.
hop: usize,
sent_at: f64,
},
/// The arm's action has gone out; waiting for the window it must open.
Await {
i: usize,
since: f64,
guid: u64,
},
/// The window is open; waiting for the re-click gate's `"npc"` token to name its NPC
/// (decision 1905). Its own phase because the latch is allowed to lag the window by a frame —
/// see [`GATE_TIMEOUT_SECS`].
Gate {
i: usize,
since: f64,
guid: u64,
},
/// The vendor fork's own chain (decision 1914) — `.go` to the pure vendor.
VendorHop {
sent_at: f64,
},
/// The EMPTY-cursor leg: `CMSG_LIST_INVENTORY` sent, waiting for the merchant window.
VendorOpen {
since: f64,
guid: u64,
},
/// `.additem` sent, waiting for the jerky to appear in a bag so it can be picked up.
VendorArm {
since: f64,
guid: u64,
},
/// The HELD-cursor leg: `CMSG_SELL_ITEM` sent, waiting for the slot to empty.
VendorSell {
since: f64,
bag: i64,
slot: u32,
},
Done,
}
/// The bag slot holding [`SELL_ITEM_ENTRY`], as `"bag,slot"`, or `""` — asked of the live VM
/// through the same bindings a player's bag UI uses, so the probe never needs its own view of the
/// container fields.
fn find_item_slot(script: &UiScript) -> Option<(i64, u32)> {
let found = script
.eval::<String>(&format!(
"for b = 0, 4 do \
local n = GetContainerNumSlots(b) or 0 \
for s = 1, n do \
local l = GetContainerItemLink(b, s) \
if l and string.find(l, \"Hitem:{SELL_ITEM_ENTRY}\") then return b .. \",\" .. s end \
end \
end return \"\""
))
.unwrap_or_default();
let (b, s) = found.split_once(',')?;
Some((b.parse().ok()?, s.parse().ok()?))
}
/// A flag word as the log wants it — the hex plus the bit names, so a reader never has to decode
/// `0x13` by hand.
fn flag_names(flags: u32) -> String {
const NAMES: [(u32, &str); 14] = [
(f::GOSSIP, "GOSSIP"),
(f::QUESTGIVER, "QUESTGIVER"),
(f::VENDOR, "VENDOR"),
(f::FLIGHTMASTER, "FLIGHTMASTER"),
(f::TRAINER, "TRAINER"),
(f::SPIRITHEALER, "SPIRITHEALER"),
(f::SPIRITGUIDE, "SPIRITGUIDE"),
(f::INNKEEPER, "INNKEEPER"),
(f::BANKER, "BANKER"),
(f::PETITIONER, "PETITIONER"),
(f::TABARDDESIGNER, "TABARDDESIGNER"),
(f::BATTLEMASTER, "BATTLEMASTER"),
(f::AUCTIONEER, "AUCTIONEER"),
(f::STABLEMASTER, "STABLEMASTER"),
];
let set: Vec<&str> = NAMES
.iter()
.filter(|(bit, _)| flags & bit != 0)
.map(|(_, n)| *n)
.collect();
match set.is_empty() {
true => format!("{flags:#x} (none)"),
false => format!("{flags:#x} {}", set.join("|")),
}
}
/// Which window is open on `guid`, if any — the observation every leg is judged on.
fn open_window(
guid: u64,
gossip: &GossipState,
giver: &QuestGiver,
trainer: &TrainerOpen,
) -> Option<Window> {
// The gossip frame does not open on the packet: it holds closed until the greeting's
// `CMSG_NPC_TEXT_QUERY` answers (B292's hold, `ui_gossip`), so the greeting is part of "open".
if gossip.npc == Some(guid) && gossip.greeting.is_some() {
return Some(Window::Gossip);
}
if giver.npc == Some(guid) && giver.view.is_some() {
return Some(Window::Quest);
}
if trainer.trainer == Some(guid) {
return Some(Window::Trainer);
}
None
}
#[allow(clippy::too_many_arguments)]
fn service_probe(
time: ProbeClock,
mut probe: ResMut<ServiceProbe>,
mut gossip: ResMut<GossipState>,
mut giver: ResMut<QuestGiver>,
mut trainer: ResMut<TrainerOpen>,
// `[0xb4e2d0]`'s mirror — what the re-click gate reads (decision 1905). The probe asserts it
// live because the gate's risk is never the `==`, it is whether this really is armed by a
// window and only by a window.
interact: Res<InteractNpc>,
// The vendor fork's leg (decision 1914): the window the empty-cursor leg must open, the VM the
// held-cursor leg picks an item up in, and the two the app needs to turn a (bag, slot) into
// the guid the wire addresses.
mut merchant: ResMut<MerchantOpen>,
script: Option<NonSendMut<UiScript>>,
items: Res<crate::items::Items>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
if self_player.is_empty() {
return; // not in-world yet
}
let now = time.elapsed_secs_f64();
match probe.phase {
Phase::Start => {
hop(&mut probe, &net, 0, 0, now);
}
Phase::Hop { i, hop: h, sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
probe.polls += 1;
let leg = &LEGS[i];
let me = player.pos;
let found = units.iter().find(|(guid, net_e, store, tf)| {
// The click's own gate: `dist² > SERVICE_RANGE_SQ` is the cursor's `unable`, and
// the arm never runs. Acting outside it would measure nothing.
if net_e.kind != EntityKind::Unit
|| tf.translation.distance_squared(me) > SCAN_RANGE_SQ
{
return false;
}
let flags = store.0.unit_npc_flags();
match leg.ident {
Ident::Entry(entry) => store.0.object_entry() == Some(entry),
Ident::PureQuestgiverWithOffer => {
flags & f::QUESTGIVER != 0
&& flags & f::GOSSIP == 0
&& crate::target::cursor_mode::questgiver_has_quest(
giver.status(guid.0),
)
}
}
});
let Some((guid, _, store, _)) = found else {
if now - sent_at > SCAN_TIMEOUT_SECS {
if h + 1 < leg.at.len() {
info!(
"PROBE_SERVICE: ({}) nothing eligible at spawn {}/{} — trying the next",
leg.name,
h + 1,
leg.at.len()
);
hop(&mut probe, &net, i, h + 1, now);
return;
}
let polls = probe.polls;
let fps = f64::from(polls) / (now - sent_at).max(0.001);
warn!(
"PROBE_SERVICE: SKIP ({}) — nothing matching streamed inside the \
{:.4}yd service reach of any of this leg's {} spawn point(s) in \
{SCAN_TIMEOUT_SECS}s each. Environmental. Scanned {polls} frame(s) \
({fps:.1} fps){}",
leg.name,
SCAN_RANGE_SQ.sqrt(),
leg.at.len(),
match fps < 5.0 {
// The reading is about the OBSERVER, not the world: below a handful of
// frames a second this leg never really looked (0713/0777/1355).
true =>
" — THE OBSERVER WAS STARVED, so this SKIP says nothing about \
the NPC. Re-run on an idle machine (`uptime`; leg.sh's guard \
is load < 3).",
false =>
" — the machine was keeping up, so the `.go` was refused, the \
tile never streamed, or this leg's NPC genuinely was not \
there.",
}
);
probe.skips += 1;
next(&mut probe, &net, i, now);
}
return;
};
let guid = guid.0;
let flags = store.0.unit_npc_flags();
let entry = store.0.object_entry().unwrap_or(0);
let status = giver.status(guid);
// The shipped ladder, on the shipped inputs.
let arm = service_arm(flags, status);
let note = match flags == leg.db_flags {
true => String::new(),
false => format!(
" [note: the world DB read {} when this leg was written]",
flag_names(leg.db_flags)
),
};
let Some(arm) = arm else {
error!(
"PROBE_SERVICE: FAIL ({}) — entry {entry} guid {guid:#x} flags {} \
status={status:?}: the ladder matched NO bit, so the click sends nothing. \
Wanted {:?}.{note}",
leg.name,
flag_names(flags),
leg.want_arm
);
probe.fails += 1;
next(&mut probe, &net, i, now);
return;
};
if arm != leg.want_arm {
error!(
"PROBE_SERVICE: FAIL ({}) — entry {entry} guid {guid:#x} flags {} \
status={status:?}: the ladder took {arm:?}, wanted {:?}. First-match-wins \
low→high over UNIT_NPC_FLAGS is the reference's own order \
(`0x5f0289`…`0x5f05bc`); a disagreement here is the dispatch, not the \
server.{note}",
leg.name,
flag_names(flags),
leg.want_arm
);
probe.fails += 1;
next(&mut probe, &net, i, now);
return;
}
// A stale window from the previous leg would answer this one's question. The
// reference's own close sends no packet, so a local clear is the whole of it.
gossip.clear();
giver.clear();
trainer.clear();
// **The first click must always get through** (decision 1905). Nothing is open here —
// this leg has just cleared, and the reference arms `[0xb4e2d0]` from window openers
// only — so the re-click gate must read disarmed at the moment of the send. If it ever
// reads armed here, the gate would be eating first clicks and this probe would still
// pass every other leg, which is why the assert is at the send and not after it.
if interact.1.is_some() {
error!(
"PROBE_SERVICE: FAIL ({}) — the npc token is armed on {:#x} BEFORE the send; \
the re-click gate would eat first clicks{note}",
leg.name,
interact.1.unwrap_or(0)
);
probe.fails += 1;
next(&mut probe, &net, i, now);
return;
}
// `None` = an empty cursor: no leg here holds an item, and the vendor arm's fork is
// the merchant probe's business, not this one's (decision 1914).
let sent = match service_action(arm, guid, false, None) {
ServiceAction::Send(cmd) => {
let named = format!("{cmd:?}");
let _ = net.0.send(cmd);
named
}
// No leg here takes a dialog arm; if one ever does, say so rather than hang.
other => {
let why: &str = match other {
ServiceAction::AskBinder => "CONFIRM_BINDER, no packet",
ServiceAction::AskSpiritHealer => "CONFIRM_XP_LOSS, no packet",
ServiceAction::Silent(w) => w,
ServiceAction::Send(_) | ServiceAction::SellFromCursor(_) => {
unreachable!()
}
};
warn!(
"PROBE_SERVICE: SKIP ({}) — arm {arm:?} opens no window from a packet \
({why}); this probe's legs are the four that do.{note}",
leg.name
);
probe.skips += 1;
next(&mut probe, &net, i, now);
return;
}
};
info!(
"PROBE_SERVICE: ({}) entry {entry} guid {guid:#x} flags {} status={status:?} \
→ arm {arm:?} → {sent}{note}",
leg.name,
flag_names(flags)
);
probe.phase = Phase::Await {
i,
since: now,
guid,
};
}
Phase::Await { i, since, guid } => {
let leg = &LEGS[i];
match open_window(guid, &gossip, &giver, &trainer) {
Some(open) if open == leg.want_window => {
info!(
"PROBE_SERVICE: PASS ({}) — {:?} window open on {guid:#x}{}",
leg.name,
open,
match open {
Window::Gossip => format!(
" ({} option(s), {} quest row(s), greeting {:?})",
gossip.options.len(),
gossip.quests.len(),
gossip.greeting.as_deref().unwrap_or("")
),
Window::Quest => format!(" ({:?})", giver.view.as_ref().map(discr)),
Window::Trainer => format!(
" ({} service(s), type {})",
trainer.services.len(),
trainer.trainer_type
),
}
);
probe.passes += 1;
probe.phase = Phase::Gate {
i,
since: now,
guid,
};
}
Some(open) => {
error!(
"PROBE_SERVICE: FAIL ({}) — {open:?} window opened on {guid:#x}, wanted \
{:?}",
leg.name, leg.want_window
);
probe.fails += 1;
next(&mut probe, &net, i, now);
}
None if now - since > WINDOW_TIMEOUT_SECS => {
// Distinguish the one near-miss that is a real defect from a dead wire: the
// gossip session latched but its greeting never resolved, so the frame stayed
// shut (`GossipState::resolve_greeting`'s "Missing gossip text!" path).
let held = gossip.npc == Some(guid) && gossip.greeting.is_none();
error!(
"PROBE_SERVICE: FAIL ({}) — no {:?} window on {guid:#x} within \
{WINDOW_TIMEOUT_SECS}s of the send{}",
leg.name,
leg.want_window,
match held {
true => format!(
" (the gossip session latched on text_id {} but the greeting never \
resolved — the frame never opens on that path)",
gossip.text_id
),
false => String::new(),
}
);
probe.fails += 1;
next(&mut probe, &net, i, now);
}
None => {}
}
}
Phase::Gate { i, since, guid } => {
let leg = &LEGS[i];
if interact.1 == Some(guid) {
info!(
"PROBE_SERVICE: PASS ({}) — re-click gate armed on {guid:#x} {:.0} ms after \
the window: a second right-click sends nothing (`0x5f0251`)",
leg.name,
(now - since) * 1000.0
);
probe.passes += 1;
next(&mut probe, &net, i, now);
} else if now - since > GATE_TIMEOUT_SECS {
error!(
"PROBE_SERVICE: FAIL ({}) — the window has been open on {guid:#x} for \
{GATE_TIMEOUT_SECS}s and the npc token still reads {:?}; the re-click gate \
cannot fire and every re-click re-sends",
leg.name, interact.1
);
probe.fails += 1;
next(&mut probe, &net, i, now);
}
}
// ── The vendor fork (decision 1914) ──────────────────────────────────────────────────
Phase::VendorHop { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let me = player.pos;
let found = units.iter().find(|(_, net_e, store, tf)| {
net_e.kind == EntityKind::Unit
&& store.0.object_entry() == Some(VENDOR_ENTRY)
&& tf.translation.distance_squared(me) <= SCAN_RANGE_SQ
});
let Some((guid, _, store, _)) = found else {
if now - sent_at > SCAN_TIMEOUT_SECS {
warn!(
"PROBE_SERVICE: SKIP (vendor fork) — entry {VENDOR_ENTRY} never streamed \
inside the service reach. Environmental."
);
probe.skips += 1;
probe.phase = Phase::Done;
}
return;
};
let guid = guid.0;
let flags = store.0.unit_npc_flags();
gossip.clear();
giver.clear();
trainer.clear();
merchant.clear();
// The EMPTY-cursor leg. The arm is asserted, not assumed: a vendor who had grown a
// GOSSIP bit would take the gossip arm and this leg would be measuring nothing.
let arm = service_arm(flags, None);
if arm != Some(ServiceArm::Vendor) {
warn!(
"PROBE_SERVICE: SKIP (vendor fork) — entry {VENDOR_ENTRY} reads {} and takes \
{arm:?}, not the vendor arm. Environmental (a world-DB change).",
flag_names(flags)
);
probe.skips += 1;
probe.phase = Phase::Done;
return;
}
match service_action(ServiceArm::Vendor, guid, false, None) {
ServiceAction::Send(cmd) => {
info!(
"PROBE_SERVICE: (vendor fork, empty cursor) {guid:#x} flags {} → {cmd:?}",
flag_names(flags)
);
let _ = net.0.send(cmd);
probe.phase = Phase::VendorOpen { since: now, guid };
}
_ => {
error!(
"PROBE_SERVICE: FAIL (vendor fork, empty cursor) — an EMPTY cursor took \
the sale leg; `0x5df5e0 je` must fall to CMSG_LIST_INVENTORY"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
}
Phase::VendorOpen { since, guid } => {
if merchant.vendor == Some(guid) {
info!(
"PROBE_SERVICE: PASS (vendor fork, empty cursor) — merchant window open on \
{guid:#x}: the zero leg is still the plain list open"
);
probe.passes += 1;
// The window would arm the re-click gate and eat the next click, so close it the
// way its own close button does (no packet) before the held-cursor leg.
merchant.clear();
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".additem {SELL_ITEM_ENTRY} 1"),
});
probe.phase = Phase::VendorArm { since: now, guid };
} else if now - since > WINDOW_TIMEOUT_SECS {
error!(
"PROBE_SERVICE: FAIL (vendor fork, empty cursor) — no merchant window on \
{guid:#x} within {WINDOW_TIMEOUT_SECS}s"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::VendorArm { since, guid } => {
let Some(mut script) = script else {
warn!("PROBE_SERVICE: SKIP (vendor fork, held cursor) — no UI VM in this build");
probe.skips += 1;
probe.phase = Phase::Done;
return;
};
let Some((bag, slot)) = find_item_slot(&script) else {
if now - since > BAG_TIMEOUT_SECS {
warn!(
"PROBE_SERVICE: SKIP (vendor fork, held cursor) — item {SELL_ITEM_ENTRY} \
never reached a bag within {BAG_TIMEOUT_SECS}s of `.additem` \
(a full bag, or the command was refused). Environmental."
);
probe.skips += 1;
probe.phase = Phase::Done;
}
return;
};
// Pick it up exactly as a player does — through the binding, not by writing the model.
if let Err(e) = script.run(&format!("PickupContainerItem({bag}, {slot})")) {
error!("PROBE_SERVICE: SKIP (vendor fork, held cursor) — PickupContainerItem: {e}");
probe.skips += 1;
probe.phase = Phase::Done;
return;
}
let Some(held) = script.cursor_item() else {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — PickupContainerItem({bag}, \
{slot}) left nothing on the cursor, so the fork has no input"
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
};
let slot0 = u8::try_from(held.slot.saturating_sub(1)).unwrap_or(0);
let Some(item_guid) = self_store
.single()
.ok()
.and_then(|s| crate::ui_items::slot_guid(&s.0, held.bag, slot0, &items))
else {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — bag {} slot {} is on the \
cursor but resolves to no item guid, so the arm cannot address the packet",
held.bag, held.slot
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
};
match service_action(ServiceArm::Vendor, guid, false, Some(item_guid)) {
ServiceAction::SellFromCursor(cmd) => {
info!(
"PROBE_SERVICE: (vendor fork, held cursor) bag {bag} slot {slot} = item \
{item_guid:#x} → {cmd:?}"
);
let _ = net.0.send(cmd);
script.take_cursor_item_for_sale();
if script.cursor_payload().is_some() {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — the sell clear left \
the item on the cursor"
);
probe.fails += 1;
}
probe.phase = Phase::VendorSell {
since: now,
bag,
slot,
};
}
other => {
let what = match other {
ServiceAction::Send(cmd) => format!("{cmd:?}"),
_ => "a non-send arm".to_string(),
};
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — an item is on the cursor \
and the vendor arm still answered {what}; `0x5df5e0` must take the sale"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
}
Phase::VendorSell { since, bag, slot } => {
let Some(script) = script else {
probe.phase = Phase::Done;
return;
};
// The server's own verdict: the sold stack leaves the bag. Nothing client-side can
// fake this — it is the inventory update answering the packet.
let gone = script
.eval::<bool>(&format!(
"return GetContainerItemLink({bag}, {slot}) == nil"
))
.unwrap_or(false);
if gone {
info!(
"PROBE_SERVICE: PASS (vendor fork, held cursor) — the server took the sale: \
bag {bag} slot {slot} is empty {:.0} ms after CMSG_SELL_ITEM",
(now - since) * 1000.0
);
probe.passes += 1;
probe.phase = Phase::Done;
} else if now - since > BAG_TIMEOUT_SECS {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — bag {bag} slot {slot} still \
holds the item {BAG_TIMEOUT_SECS}s after the sale went out; the server \
refused it (wrong vendor guid, wrong item guid, or out of range)"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
info!(
"PROBE_SERVICE: DONE pass={} fail={} skip={}",
probe.passes, probe.fails, probe.skips
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_SERVICE: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
/// The open quest panel's shape, for the PASS line — `Greeting` (the multi-quest list) vs
/// `Detail` (the single quest's own frame) is exactly the distinction a reader wants here.
fn discr(view: &crate::ui_quest::QuestView) -> &'static str {
use crate::ui_quest::QuestView as V;
match view {
V::Greeting(_) => "Greeting",
V::Detail(_) => "Detail",
V::Progress(_) => "Progress",
V::Reward(_) => "Reward",
}
}
/// Send leg `i`'s `.go` for spawn point `h` and enter [`Phase::Hop`].
fn hop(probe: &mut ServiceProbe, net: &NetCommands, i: usize, h: usize, now: f64) {
let leg = &LEGS[i];
let [x, y, z] = leg.at[h];
info!(
"PROBE_SERVICE: hopping to {} at ({x}, {y}, {z}) map {} (spawn {}/{})",
leg.name,
leg.map,
h + 1,
leg.at.len()
);
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {}", leg.map),
});
probe.polls = 0;
probe.phase = Phase::Hop {
i,
hop: h,
sent_at: now,
};
}
/// Advance past leg `i`, or finish.
fn next(probe: &mut ServiceProbe, net: &NetCommands, i: usize, now: f64) {
match i + 1 < LEGS.len() {
true => hop(probe, net, i + 1, 0, now),
// The ladder legs are done; the vendor fork's own chain runs after them.
false => {
let [x, y, z] = VENDOR_AT;
info!(
"PROBE_SERVICE: hopping to the pure vendor (entry {VENDOR_ENTRY}) at \
({x}, {y}, {z}) map {VENDOR_MAP}"
);
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {VENDOR_MAP}"),
});
probe.phase = Phase::VendorHop { sent_at: now };
}
}
}
+8 -1
View File
@@ -48,7 +48,14 @@ impl Plugin for ProbeTaxiPlugin {
load_expectations.after(benilla_assets::AssetSet::Open),
)
.add_systems(Update, taxi_probe)
.add_systems(PreUpdate, hold_w_post_land.after(bevy::input::InputSystems));
.add_systems(
PreUpdate,
// After the loading cover's input swallow as well as winit's input pass — see
// `probes::act::ProbeKeyPlugin` for why a synthetic press orders past it.
hold_w_post_land
.after(bevy::input::InputSystems)
.after(crate::loading_screen::CoverInput),
);
}
}
@@ -0,0 +1,426 @@
//! The **vendor-swap** live probe (`WOW_PROBE_VENDOR_SWAP=1`) — decision 2022's end-to-end
//! instrument: does a second vendor, opened while the first vendor's window is still up, swap the
//! WHOLE window — the stock `MerchantFrame_UpdateMerchantInfo`'s `UnitName("NPC")` title and its
//! `SetPortraitTexture(MerchantFramePortrait, "NPC")` face — on the very dispatch that raises
//! `MERCHANT_SHOW`; and does closing the window clear the `"npc"` token?
//!
//! The director's report (2026-09-05): the Goldshire inn's vendor window titled "Innkeeper
//! Farley" over Brog Hamfist's stock, with an empty portrait ring; then "clicking between vendors
//! while the window is open doesn't swap name and some other things maybe too". Both faces of
//! one seam — the decision has the mechanism. This probe stands in that inn, between its two
//! pure vendors (**Brog Hamfist** 151 and **Barkeep Dobbins** 465, 5.45 yd apart — live-DB
//! verified), so both are inside the service reach at once and the second open lands on top of
//! the first window exactly as the click does.
//!
//! Legs, one `PROBE_VENDOR_SWAP: <leg> PASS/FAIL/SKIP` line each:
//! 1. **open** — `CMSG_LIST_INVENTORY` to Brog; at the `MERCHANT_SHOW` dispatch, `UnitName("npc")`
//! and the title both read Brog's name.
//! 2. **portrait** — the round region is being sampled under the canonical `"npc"` key
//! (`portrait::BoothPanes`) and the booth publishes a live bake for it (`PortraitImages`).
//! 3. **swap** — with Brog's window still open, `CMSG_LIST_INVENTORY` to Dobbins; at that
//! `MERCHANT_SHOW`, both readings are Dobbins' name.
//! 4. **title** — a second later, the title the window is actually left showing is his too.
//! 5. **clear** — `HideUIPanel(MerchantFrame)` (the stock OnHide → `CloseMerchant`), and once the
//! session is gone `UnitExists("npc")` is nil.
//!
//! The event readings are taken **inside the event** by a Lua hook frame registered after the
//! stock window's, so a later `MERCHANT_UPDATE` repaint cannot mask what the open frame saw —
//! which is how the bug hid: a vendor whose items were already cached got no repaint, and one
//! whose items were still streaming got one a moment later that quietly corrected the title.
//!
//! Non-combat, nothing bought, nothing sold; the window is closed the way its own close button
//! closes it (no packet).
//!
//! ## The run recipe
//! ```text
//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone WOW_UNATTENDED=1 WOW_NOSOUND=1 \
//! WOW_PROBE_VENDOR_SWAP=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — method.md "The local vmangos server").
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::names::NameCache;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::portrait::{BoothPanes, PortraitImages, PortraitSource};
use crate::ui_merchant::MerchantOpen;
/// Brog Hamfist `<General Supplies>` — `creature_template.entry = 151`, `npc_flags = 4`.
const BROG_ENTRY: u32 = 151;
/// Barkeep Dobbins — `entry = 465`, `npc_flags = 4`.
const DOBBINS_ENTRY: u32 = 465;
/// Midway between the two spawns (`creature.position_*`: Brog −9465.29, 9.63; Dobbins −9459.98,
/// 8.41; both z 57.15), so each is ~2.8 yd away — inside the 5.5556 yd service reach.
const STAND_AT: [f32; 3] = [-9462.6, 9.0, 57.15];
const MAP: u32 = 0;
/// Let the hop land and the inn stream before the first scan.
const SETTLE_SECS: f64 = 3.0;
/// How long to wait for both vendors (and their names) before calling the hop environmental.
const SCAN_TIMEOUT_SECS: f64 = 20.0;
/// How long a leg waits for its window / its event after the opcode goes out.
const WINDOW_TIMEOUT_SECS: f64 = 15.0;
/// How long the round portrait may take to be sampled and baked once the window is open.
const PORTRAIT_TIMEOUT_SECS: f64 = 10.0;
/// How long after the swap to read the title the window is actually left showing.
const SETTLED_SECS: f64 = 1.0;
/// The service reach the click cannot act past (`crate::target::SERVICE_RANGE_SQ`).
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
/// The hook frame: one line per merchant event, read INSIDE the dispatch. Registered after the
/// stock `MerchantFrame` (the probe installs it in-world, long after the FrameXML load), so the
/// stock `OnEvent` has already repainted by the time this runs and `MerchantNameText` shows what
/// the open frame left there.
const HOOK: &str = r#"
if not ProbeVendorSwapHooked then
ProbeVendorSwapHooked = true
ProbeVendorSwapLog = {}
local f = CreateFrame("Frame")
f:RegisterEvent("MERCHANT_SHOW")
f:RegisterEvent("MERCHANT_UPDATE")
f:RegisterEvent("MERCHANT_CLOSED")
f:SetScript("OnEvent", function()
table.insert(ProbeVendorSwapLog,
event .. "|" .. tostring(UnitName("npc")) .. "|" .. tostring(MerchantNameText:GetText()))
end)
end
"#;
pub(crate) struct ProbeVendorSwapPlugin;
impl Plugin for ProbeVendorSwapPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<VendorSwapProbe>()
.add_systems(Update, vendor_swap_probe);
}
}
#[derive(Resource, Default)]
struct VendorSwapProbe {
phase: Phase,
passes: u32,
fails: u32,
skips: u32,
brog: Option<u64>,
dobbins: Option<u64>,
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Start,
/// `.go` sent; waiting for both vendors to stream in reach, with their names cached.
Settle {
sent_at: f64,
},
/// Brog's `CMSG_LIST_INVENTORY` sent; waiting for his window and its `MERCHANT_SHOW`.
Open {
since: f64,
},
/// Brog's window is open; waiting for the round portrait to be sampled and baked.
Portrait {
since: f64,
},
/// Dobbins' `CMSG_LIST_INVENTORY` sent over Brog's open window; waiting for the swap.
Swap {
since: f64,
},
/// The swap happened; reading the title the window settles on.
Settled {
since: f64,
},
/// `HideUIPanel(MerchantFrame)` run; waiting for the session to clear, then one more poll.
Clear {
since: f64,
closed_at: Option<f64>,
},
Done,
}
/// The first `MERCHANT_SHOW` line in the hook's log, split into `(UnitName("npc"), title)`.
fn first_show(script: &UiScript) -> Option<(String, String)> {
let log: Vec<String> = script.eval("return ProbeVendorSwapLog").unwrap_or_default();
log.iter().find_map(|line| {
let mut parts = line.splitn(3, '|');
match (parts.next(), parts.next(), parts.next()) {
(Some("MERCHANT_SHOW"), Some(npc), Some(title)) => {
Some((npc.to_string(), title.to_string()))
}
_ => None,
}
})
}
fn clear_log(script: &UiScript) {
if let Err(e) = script.run("ProbeVendorSwapLog = {}") {
error!("PROBE_VENDOR_SWAP: clearing the hook log: {e}");
}
}
#[allow(clippy::too_many_arguments)]
fn vendor_swap_probe(
time: ProbeClock,
mut probe: ResMut<VendorSwapProbe>,
merchant: Res<MerchantOpen>,
script: Option<NonSendMut<UiScript>>,
mut names: ResMut<NameCache>,
panes: Res<BoothPanes>,
images: Res<PortraitImages>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
if self_player.is_empty() {
return; // not in-world yet
}
let now = time.elapsed_secs_f64();
let Some(script) = script else {
return;
};
match probe.phase {
Phase::Start => {
if let Err(e) = script.run(HOOK) {
error!("PROBE_VENDOR_SWAP: installing the merchant hook: {e}");
probe.phase = Phase::Done;
return;
}
let [x, y, z] = STAND_AT;
info!("PROBE_VENDOR_SWAP: hopping to the Goldshire inn ({x}, {y}, {z}) map {MAP}");
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {MAP}"),
});
probe.phase = Phase::Settle { sent_at: now };
}
Phase::Settle { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let me = player.pos;
let find = |entry: u32| {
units
.iter()
.find(|(_, net_e, store, tf)| {
net_e.kind == EntityKind::Unit
&& store.0.object_entry() == Some(entry)
&& tf.translation.distance_squared(me) <= SCAN_RANGE_SQ
})
.map(|(guid, ..)| guid.0)
};
let (brog, dobbins) = (find(BROG_ENTRY), find(DOBBINS_ENTRY));
// Both names must be cached before the legs read them back out of the VM — the
// ask-once resolve is what the unit feed itself would send on the open frame.
let mut named = |g: Option<u64>| g.is_some_and(|g| names.resolve(g, &net).is_some());
let (brog_named, dobbins_named) = (named(brog), named(dobbins));
if brog_named && dobbins_named {
probe.brog = brog;
probe.dobbins = dobbins;
let brog = brog.expect("named");
info!(
"PROBE_VENDOR_SWAP: Brog {brog:#x} and Dobbins {:#x} both in reach — \
CMSG_LIST_INVENTORY to Brog",
dobbins.expect("named")
);
clear_log(&script);
let _ = net.0.send(ClientCommand::ListInventory { guid: brog });
probe.phase = Phase::Open { since: now };
} else if now - sent_at > SCAN_TIMEOUT_SECS {
warn!(
"PROBE_VENDOR_SWAP: SKIP — vendors in reach: Brog {brog:?}, Dobbins \
{dobbins:?} (names cached: {}/{}). Environmental (the `.go` refused, or the \
inn never streamed).",
brog_named, dobbins_named
);
probe.skips += 1;
probe.phase = Phase::Done;
}
}
Phase::Open { since } => {
let brog = probe.brog.expect("set at Settle");
let expect = names.peek(brog).unwrap_or("").to_string();
match (merchant.vendor == Some(brog), first_show(&script)) {
(true, Some((npc, title))) => {
if npc == expect && title == expect {
info!(
"PROBE_VENDOR_SWAP: open PASS — at MERCHANT_SHOW UnitName(\"npc\") \
= {npc:?}, title = {title:?}"
);
probe.passes += 1;
} else {
error!(
"PROBE_VENDOR_SWAP: open FAIL — at MERCHANT_SHOW UnitName(\"npc\") \
= {npc:?}, title = {title:?}; expected {expect:?} for both"
);
probe.fails += 1;
}
probe.phase = Phase::Portrait { since: now };
}
_ if now - since > WINDOW_TIMEOUT_SECS => {
error!(
"PROBE_VENDOR_SWAP: open FAIL — no merchant window + MERCHANT_SHOW on \
Brog within {WINDOW_TIMEOUT_SECS}s (vendor = {:?})",
merchant.vendor
);
probe.fails += 1;
probe.phase = Phase::Done;
}
_ => {}
}
}
Phase::Portrait { since } => {
let sampled = panes.0.contains_key("npc");
let baked = matches!(images.0.get("npc"), Some(PortraitSource::Live(_)));
if sampled && baked {
info!(
"PROBE_VENDOR_SWAP: portrait PASS — the round region samples the \"npc\" \
slot and the booth publishes a live bake for it"
);
probe.passes += 1;
} else if now - since > PORTRAIT_TIMEOUT_SECS {
let mut keys: Vec<&String> = panes.0.keys().collect();
keys.sort();
error!(
"PROBE_VENDOR_SWAP: portrait FAIL — sampled under \"npc\": {sampled}, live \
bake: {baked}; pane keys published this frame: {keys:?}"
);
probe.fails += 1;
} else {
return;
}
let dobbins = probe.dobbins.expect("set at Settle");
info!("PROBE_VENDOR_SWAP: CMSG_LIST_INVENTORY to Dobbins over Brog's open window");
clear_log(&script);
let _ = net.0.send(ClientCommand::ListInventory { guid: dobbins });
probe.phase = Phase::Swap { since: now };
}
Phase::Swap { since } => {
let dobbins = probe.dobbins.expect("set at Settle");
let expect = names.peek(dobbins).unwrap_or("").to_string();
match (merchant.vendor == Some(dobbins), first_show(&script)) {
(true, Some((npc, title))) => {
if npc == expect && title == expect {
info!(
"PROBE_VENDOR_SWAP: swap PASS — at MERCHANT_SHOW UnitName(\"npc\") \
= {npc:?}, title = {title:?}"
);
probe.passes += 1;
} else {
error!(
"PROBE_VENDOR_SWAP: swap FAIL — at MERCHANT_SHOW UnitName(\"npc\") \
= {npc:?}, title = {title:?}; expected {expect:?} for both"
);
probe.fails += 1;
}
probe.phase = Phase::Settled { since: now };
}
_ if now - since > WINDOW_TIMEOUT_SECS => {
error!(
"PROBE_VENDOR_SWAP: swap FAIL — no window swap + MERCHANT_SHOW on \
Dobbins within {WINDOW_TIMEOUT_SECS}s (vendor = {:?})",
merchant.vendor
);
probe.fails += 1;
probe.phase = Phase::Done;
}
_ => {}
}
}
Phase::Settled { since } => {
if now - since < SETTLED_SECS {
return;
}
let dobbins = probe.dobbins.expect("set at Settle");
let expect = names.peek(dobbins).unwrap_or("").to_string();
let title: String = script
.eval("return tostring(MerchantNameText:GetText())")
.unwrap_or_default();
if title == expect {
info!("PROBE_VENDOR_SWAP: title PASS — the window settled on {title:?}");
probe.passes += 1;
} else {
error!(
"PROBE_VENDOR_SWAP: title FAIL — the window settled on {title:?}, expected \
{expect:?}"
);
probe.fails += 1;
}
if let Err(e) = script.run("HideUIPanel(MerchantFrame)") {
error!("PROBE_VENDOR_SWAP: HideUIPanel(MerchantFrame): {e}");
}
probe.phase = Phase::Clear {
since: now,
closed_at: None,
};
}
Phase::Clear { since, closed_at } => {
match closed_at {
None if merchant.vendor.is_none() => {
probe.phase = Phase::Clear {
since,
closed_at: Some(now),
};
}
None if now - since > WINDOW_TIMEOUT_SECS => {
error!(
"PROBE_VENDOR_SWAP: clear FAIL — the session never cleared after \
HideUIPanel (vendor = {:?})",
merchant.vendor
);
probe.fails += 1;
probe.phase = Phase::Done;
}
None => {}
// One more poll after the clear: the unit feed pushes the token's absence on the
// frame after the session clears.
Some(at) if now - at >= 0.25 => {
// `and 1 or 0`: the binding answers a Lua boolean where the reference
// answers `1`/nil (its own noted question), and a `false ~= nil` is true.
let exists: i64 = script
.eval(r#"return UnitExists("npc") and 1 or 0"#)
.unwrap_or(1);
if exists == 1 {
error!(
"PROBE_VENDOR_SWAP: clear FAIL — UnitExists(\"npc\") is still true \
after the window closed"
);
probe.fails += 1;
} else {
info!("PROBE_VENDOR_SWAP: clear PASS — UnitExists(\"npc\") is nil");
probe.passes += 1;
}
probe.phase = Phase::Done;
}
Some(_) => {}
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
info!(
"PROBE_VENDOR_SWAP: DONE pass={} fail={} skip={}",
probe.passes, probe.fails, probe.skips
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_VENDOR_SWAP: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
+199 -33
View File
@@ -145,7 +145,12 @@ impl Plugin for ProbeKeyPlugin {
app.insert_resource(ProbeKeys { taps, armed: false })
.add_systems(
bevy::app::PreUpdate,
fire_probe_key.after(bevy::input::InputSystems),
fire_probe_key
.after(bevy::input::InputSystems)
// …and after the loading cover's input swallow, which runs in the same window
// and empties every button plane: an instrument driving the client is the
// operator, not the player, so its press has to land on the far side of it.
.after(crate::loading_screen::CoverInput),
);
}
}
@@ -186,6 +191,10 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
"F" => KeyCode::KeyF,
"Ctrl" => KeyCode::ControlLeft,
"Shift" => KeyCode::ShiftLeft,
// The cost pill's toggle, dev chord + `P` (`perf::hud::toggle_hud`). Reachable no other
// way — it is a host chord, not a binding or a Lua verb — and since the pill starts hidden
// (2099) a probe that wants to see it in a shot has to press it.
"P" => KeyCode::KeyP,
// The text-editing keys. Added for decision 1077's hyperlink-atomicity law, whose whole
// observable — "one BACKSPACE removes a whole item link" — is a keypress no chat command
// and no Lua chunk can reach (`EditBox` has no Lua deletion API; the law lives behind the
@@ -300,12 +309,26 @@ fn fire_probe_key(
}
}
/// The PROBE LUA one-shot (`WOW_PROBE_LUA="<chunk>"`, delay via `WOW_PROBE_LUA_AT` seconds,
/// default 10): run one Lua chunk in the live UI VM once we are in-world — the "press the button
/// headlessly" instrument. The chunk drives the REAL FrameXML API surface (`CastSpell`,
/// `UseAction`, `TargetUnit`, …), so whatever it triggers takes the exact app path a click
/// takes — a headless wire probe can measure the server, but only the live VM exercises the
/// button feed and the widget clock.
/// The PROBE LUA driver (`WOW_PROBE_LUA="<chunk>"`, first fire at `WOW_PROBE_LUA_AT` seconds,
/// default 10; re-armed `WOW_PROBE_LUA_AGAIN` seconds into every LATER world entry, default 4):
/// run one Lua chunk in the live UI VM — the "press the button headlessly" instrument. The chunk
/// drives the REAL FrameXML API surface (`CastSpell`, `UseAction`, `TargetUnit`, …), so whatever
/// it triggers takes the exact app path a click takes — a headless wire probe can measure the
/// server, but only the live VM exercises the button feed and the widget clock.
///
/// **The chunk fires once per WORLD ENTRY, not once per process** (decision 2116's named gap).
/// The UI VM is destroyed and rebuilt at every logout/login (1290/1291), taking `_G` and the
/// `ProbeLog` channel below with it — so while this was a process-wide one-shot, a probe could
/// not read the same value before and after a relog *at all*. 2116 needed exactly that (the
/// cooldown triple across a `/logout`) and had to fall back to `WOW_PROBE_CHAT` sending
/// `/script … error("…")` on a schedule and reading the warn line the input drain logs — a trick
/// standing in for an instrument, and a morning instead of an hour.
///
/// **World entry, and deliberately not "VM rebuild"**, which is the wider edge and the wrong one:
/// `ReloadUI()` rebuilds the VM without leaving the world, and `WOW_PROBE_LUA="ReloadUI()"` is a
/// probe we actually run (decision 2028). Re-arming on the VM would make that chunk reload the
/// interface forever. [`ProbeLog`](install_probe_log) *does* follow the VM, because it is a
/// channel rather than an action.
pub(crate) struct ProbeLuaPlugin;
impl Plugin for ProbeLuaPlugin {
@@ -315,48 +338,77 @@ impl Plugin for ProbeLuaPlugin {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10.0);
let again = std::env::var("WOW_PROBE_LUA_AGAIN")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(4.0);
app.insert_resource(ProbeLua {
chunk,
at,
fired: false,
again,
..default()
})
.add_systems(Update, fire_probe_lua);
}
}
/// [`ProbeLuaPlugin`] state: the chunk, the fire time, and the once-latch.
#[derive(Resource)]
/// [`ProbeLuaPlugin`] state: the chunk, the two schedules, and the per-entry bookkeeping that
/// replaced the process-wide once-latch.
#[derive(Resource, Default)]
struct ProbeLua {
chunk: String,
/// `WOW_PROBE_LUA_AT` — the FIRST fire, on the run's own [`ProbeClock`].
at: f32,
fired: bool,
/// `WOW_PROBE_LUA_AGAIN` — the settle every LATER entry gets, on its own clock.
again: f32,
/// World entries that have become able to take a chunk — counted on the rising edge.
entries: u32,
/// Entries already fired into; `fired < entries` is "this entry is still owed its chunk".
fired: u32,
/// When the current entry became ready — the re-arm's zero.
ready_at: f32,
/// Last frame's readiness, so the count above is an edge and not a per-frame increment.
was_ready: bool,
/// The VM session `ProbeLog` was last installed into. `0` is "none" —
/// [`benilla_ui::script::UiScript::session`] hands out from 1, so a fresh probe matches no VM
/// and the first ready frame installs.
logged_vm: u64,
}
/// Run the probe chunk once the delay has elapsed AND the session is in-world.
fn fire_probe_lua(
mut probe: ResMut<ProbeLua>,
time: ProbeClock,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
) {
if probe.fired || probe.chunk.is_empty() || time.elapsed_secs() < probe.at {
return;
/// **Is this entry's chunk due?** — the whole schedule, as one decision a test can hold.
///
/// The first fire keeps `WOW_PROBE_LUA_AT`'s run-relative meaning ([`ProbeClock`], whose zero
/// trails process start by the boot), and that is not inertia: every recorded probe line in
/// `decisions/` is tuned against it (`WOW_PROBE_LUA_AT=34` in `scripts/summon-live.sh`, and a
/// dozen records besides), and re-reading it as "34 s after the loading screen" would quietly
/// stop reproducing all of them. A later entry cannot use that
/// clock at all — "34 s into the run" is long past by the time a relog lands — so it runs on
/// its own settle instead, measured from the moment that entry could take a chunk.
fn probe_lua_due(probe: &ProbeLua, now: f32) -> bool {
if probe.fired >= probe.entries {
return false; // this entry has had its chunk (or there is no entry yet)
}
if self_player.is_empty() {
return; // not in-world yet — keep waiting past the delay
if probe.fired == 0 {
now >= probe.at
} else {
now >= probe.ready_at + probe.again
}
let Some(script) = script else {
return;
};
probe.fired = true;
// `ProbeLog(text)` — the chunk's data channel OUT of the VM (greppable `probe-log:` lines);
// until now a probe could only report through screenshots or by erroring. Installed only
// when a probe chunk actually fires — never part of the shipping API surface.
let install = script.lua().create_function(|_, text: String| {
}
/// Install `ProbeLog(text)` — the chunk's data channel OUT of the VM, as greppable `probe-log:`
/// lines; before it existed a probe could only report through screenshots or by erroring.
///
/// **Into whichever VM is live, and re-installed whenever that changes** (decision 2116). A
/// global installed once is gone at the next logout/login *and* at the next `ReloadUI()`, both of
/// which build a new Lua state (1290/1291) — so a chunk that logged happily on its first run died
/// on a nil call afterwards, which is precisely the shape of failure that reads as "the probe did
/// nothing". Installed only while a probe chunk is configured; never part of the shipping API
/// surface.
fn install_probe_log(script: &benilla_ui::script::UiScript) {
match script.lua().create_function(|_, text: String| {
info!("probe-log: {text}");
Ok(())
});
match install {
}) {
Ok(f) => {
if let Err(e) = script.lua().globals().set("ProbeLog", f) {
error!("probe-lua: installing ProbeLog: {e}");
@@ -364,7 +416,63 @@ fn fire_probe_lua(
}
Err(e) => error!("probe-lua: creating ProbeLog: {e}"),
}
info!("probe-lua: running {:?}", probe.chunk);
}
/// Run the probe chunk once per world entry, on [`probe_lua_due`]'s schedule.
fn fire_probe_lua(
mut probe: ResMut<ProbeLua>,
time: ProbeClock,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
state: Res<State<crate::char_select::ClientState>>,
entry_ui_pending: Option<Res<crate::ui_script::PendingEntryUiLoad>>,
) {
if probe.chunk.is_empty() {
return;
}
let Some(script) = script else {
// No VM in the world at all — the deferral window between the world-entry edge and the
// entry load, where the boot VM is parked out of every feed's reach
// (`ui_script::ParkedBootVm`). Not an entry yet.
probe.was_ready = false;
return;
};
// **Ready = this entry can take a chunk**, which is three more facts on top of the VM the
// block above just took. A chunk driving `CastSpell` against the *boot* VM — strings, emote
// tokens and fonts, not one frame — dies on a nil, and "attempt to call a nil value" out of a
// probe reads as a benilla bug rather than as a mis-scheduled probe.
//
// - **`InWorld`** — the entry edge has been taken, which is what ARMS the deferral window
// below. Without it the window has a hole on its leading side: the entered-world message
// and our first object update can arrive in the same drain, a frame ahead of the state
// transition that parks the VM, so a chunk already past `at` would run there.
// - **`PendingEntryUiLoad` gone** — the same window named from the other side: the in-game
// UI is built, not merely owed.
// - **a self player** — `UnitName("player")` is in half the chunks anyone writes.
let in_world = *state.get() == crate::char_select::ClientState::InWorld;
let ready = in_world && entry_ui_pending.is_none() && !self_player.is_empty();
let now = time.elapsed_secs();
if ready && !probe.was_ready {
probe.entries += 1;
probe.ready_at = now;
info!("probe-lua: world entry {} at {now:.1}s", probe.entries);
}
probe.was_ready = ready;
if !ready {
return;
}
if probe.logged_vm != script.session() {
install_probe_log(&script);
probe.logged_vm = script.session();
}
if !probe_lua_due(&probe, now) {
return;
}
probe.fired = probe.entries;
info!(
"probe-lua: entry {} at {now:.1}s — running {:?}",
probe.entries, probe.chunk
);
if let Err(e) = script.run(&probe.chunk) {
error!("probe-lua: {e}");
}
@@ -740,3 +848,61 @@ fn fire_probe_drag(
}
probe.phase += 1;
}
#[cfg(test)]
mod tests {
use super::{probe_lua_due, ProbeLua};
/// A probe configured the way `WOW_PROBE_LUA=… WOW_PROBE_LUA_AT=10` configures one, with the
/// per-entry bookkeeping set by hand.
fn probe(entries: u32, fired: u32, ready_at: f32) -> ProbeLua {
ProbeLua {
chunk: "ProbeLog('x')".into(),
at: 10.0,
again: 4.0,
entries,
fired,
ready_at,
..Default::default()
}
}
/// The first entry is still `WOW_PROBE_LUA_AT` on the PROCESS clock — the meaning every
/// recorded probe line in `decisions/` is tuned against.
#[test]
fn first_fire_waits_for_the_process_delay() {
let p = probe(1, 0, 2.0);
assert!(!probe_lua_due(&p, 9.9), "before WOW_PROBE_LUA_AT");
assert!(probe_lua_due(&p, 10.0), "at WOW_PROBE_LUA_AT");
}
/// Nothing fires before there is a world entry to fire into — the login and character
/// screens sit here however long they take.
#[test]
fn nothing_fires_before_the_first_entry() {
assert!(!probe_lua_due(&probe(0, 0, 0.0), 60.0));
}
/// One chunk per entry, and no more: the frames after a fire are not a second one.
#[test]
fn an_entry_takes_exactly_one_chunk() {
assert!(!probe_lua_due(&probe(1, 1, 2.0), 60.0));
assert!(!probe_lua_due(&probe(2, 2, 40.0), 90.0));
}
/// **The gap 2116 hit head-on**: the second entry re-arms, on its OWN clock. The process
/// delay is long past by the time a relog lands, so reusing it would fire the chunk into the
/// loading screen; the settle is measured from the moment that entry could take a chunk.
#[test]
fn a_later_entry_re_arms_on_its_own_clock() {
let p = probe(2, 1, 40.0);
assert!(
!probe_lua_due(&p, 41.0),
"still settling after the re-entry"
);
assert!(
probe_lua_due(&p, 44.0),
"WOW_PROBE_LUA_AGAIN past the re-entry"
);
}
}
@@ -72,6 +72,7 @@ struct DressCensus {
/// What the census reads per entity: identity, kind, pose, the resolved worn set, and the attach
/// roots actually standing.
type DressQuery = (
Entity,
&'static Guid,
&'static NetEntity,
&'static Transform,
@@ -81,12 +82,22 @@ type DressQuery = (
);
/// One line per streamed player within [`DEFAULT_RADIUS`], contradictions first.
#[allow(clippy::too_many_arguments)]
fn fire_dress_census(
mut probe: ResMut<DressCensus>,
time: ProbeClock,
names: Res<NameCache>,
body: Query<&Transform, With<SelfPlayer>>,
body: Query<(Entity, &Transform), With<SelfPlayer>>,
// The self body's parts, one line each (`DRESS_PART`) — the draw-population inventory.
body_parts: crate::entities::BodyPartsDesc,
entities: Query<DressQuery>,
// The body's draw population (`parts=`/`mats=`): every mesh part hanging under the unit —
// body batches, each attach model's batches, cards — and how many DISTINCT materials
// they bind. The gap between the two is the ceiling on merging a body's batches by
// material (1929's first proposal): what a merge could collapse, read off a dressed body
// instead of guessed from a model's batch table.
children: Query<&Children>,
parts: Query<&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>,
) {
let now = time.elapsed_secs();
if probe.next <= 0.0 || now < probe.next {
@@ -97,7 +108,7 @@ fn fire_dress_census(
} else {
-1.0
};
let Ok(body) = body.single() else {
let Ok((self_entity, body)) = body.single() else {
println!("DRESS_CENSUS t={now:.1} NO BODY — not in world, nothing measured");
return;
};
@@ -109,7 +120,7 @@ fn fire_dress_census(
// `(is_contradiction, sort key, line)`. A contradiction sorts to the top: it is the finding.
let mut rows: Vec<(bool, i64, String)> = Vec::new();
let (mut hiding_helm, mut hiding_cloak, mut bad) = (0u32, 0u32, 0u32);
for (guid, net, t, store, equipment, attached) in &entities {
for (unit, guid, net, t, store, equipment, attached) in &entities {
if net.kind != EntityKind::Player
|| t.translation.distance_squared(body.translation) > radius * radius
{
@@ -140,12 +151,13 @@ fn fire_dress_census(
(false, false) => "-",
};
let dist = t.translation.distance(body.translation);
let (n_parts, n_mats) = draw_population(unit, &children, &parts);
rows.push((
contradiction,
(dist * 100.0) as i64,
format!(
"DRESS {:#018x} d={dist:6.1} flags={flags:#010x} hide={hide:<10} \
helm={:<6} cloak={:<6} settled={} spawned=[{}] {}{}",
helm={:<6} cloak={:<6} settled={} spawned=[{}] parts={n_parts} mats={n_mats} {}{}",
guid.0,
eq.helm,
eq.cloak,
@@ -169,4 +181,24 @@ fn fire_dress_census(
for (_, _, line) in &rows {
println!("{line}");
}
for line in body_parts.describe(self_entity, &children) {
println!("{line}");
}
}
/// Mesh parts under `unit` (every depth) and the distinct materials they bind.
fn draw_population(
unit: Entity,
children: &Query<&Children>,
parts: &Query<&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>,
) -> (usize, usize) {
let mut n = 0usize;
let mut mats = std::collections::HashSet::new();
for e in children.iter_descendants(unit) {
if let Ok(m) = parts.get(e) {
n += 1;
mats.insert(m.0.id());
}
}
(n, mats.len())
}
@@ -8,7 +8,7 @@
//! per unit:
//!
//! ```text
//! UGD 0x… z=5.02 seat=9.14 drop=+4.12 terrain=5.02 above=9.07 (+4.05) spline=0 swim=0
//! UGD 0x… unit "Stormwind Guard" z=5.02 seat=9.14 drop=+4.12 terrain=5.02 above=9.07 (+4.05) spline=0 swim=0
//! ```
//!
//! - **`seat`** is the server's own Z for this unit — the pose the create block / a move packet /
@@ -33,13 +33,23 @@
//! The repeat form is the load-race half: a census at 30 s and again at 45 s says whether a sunk
//! unit *stays* sunk once the world has finished arriving, which is what separates a transient from
//! the ratchet decision 1384 removed.
//!
//! It lists exactly the bodies the clamp is responsible for ([`ground_derived`]) — since B357 that
//! includes a **player the server moves along a spline**, which the `player` tag names. That is the
//! whole readout for a Playerbots server: a bot mid-path reads `player spline=1` with a small
//! `drop`, and the bug it closes read the wire Z with no row here at all.
use benilla_assets::coords::bevy_to_wow;
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use super::ProbeClock;
use crate::net::{CreatureSwimming, GroundClamped, Guid, NetEntity, SelfPlayer, Spline};
use crate::names::NameCache;
use crate::net::NetCommands;
use crate::net::{
ground_derived, CreatureSwimming, Embodied, GroundClamped, Guid, NetEntity, RemoteMotion,
SelfPlayer, Spline,
};
/// A drop past this (yd) counts as **sunk** — the headline count. The clamp's honest corrections are
/// the small float off a slightly-high wire Z and the little hill a straight spline cuts through;
@@ -96,13 +106,24 @@ type CensusQuery = (
Option<&'static GroundClamped>,
Option<&'static Spline>,
Has<CreatureSwimming>,
// The subject test's other two inputs, so the listing is exactly the clamp's own population
// rather than a second, hand-kept guess at it.
Has<Embodied>,
Has<RemoteMotion>,
);
/// One census line per streamed `Unit` within [`GroundCensus::radius`] of the body, worst drop
/// One census line per [`ground_derived`] body within [`GroundCensus::radius`] of ours, worst drop
/// first, under a summary line naming the count that matters.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
fn fire_ground_census(
mut probe: ResMut<GroundCensus>,
time: ProbeClock,
// The name each row belongs to. "Which NPC is sunk" is a name question — a guid sends the
// reader back to the server's own tables to answer it — and the cache is already warm: it is
// asked at first *sight* for every unit that streams in. A `None` is a name still in flight,
// never a missing one.
mut names: ResMut<NameCache>,
net_commands: Res<NetCommands>,
world: benilla_world::collision::WorldCollision,
point: benilla_world::world_point::WorldPoint,
body: Query<&Transform, With<SelfPlayer>>,
@@ -124,9 +145,14 @@ fn fire_ground_census(
let radius2 = probe.radius * probe.radius;
let mut rows: Vec<(f32, bool, String)> = Vec::new();
for (guid, net, t, clamped, spline, swimming) in &units {
if net.kind != EntityKind::Unit
|| t.translation.distance_squared(body.translation) > radius2
for (guid, net, t, clamped, spline, swimming, embodied, relayed) in &units {
if !ground_derived(
net.kind,
spline.is_some(),
clamped.is_some(),
embodied,
relayed,
) || t.translation.distance_squared(body.translation) > radius2
{
continue;
}
@@ -136,13 +162,28 @@ fn fire_ground_census(
let terrain = point.terrain_height_under(t.translation);
let above = lowest_surface_above(&world, t.translation);
let wow = bevy_to_wow(t.translation);
let name = names
.resolve(guid.0, &net_commands)
.unwrap_or("?")
.to_string();
rows.push((
drop,
swimming,
format!(
"UGD {:#018x} pos=({:.2},{:.2},{:.2}) z={z:.2} seat={seat:.2} drop={drop:+.2} \
terrain={} above={} spline={} swim={}",
"UGD {:#018x} {} {name:?} display={} pos=({:.2},{:.2},{:.2}) z={z:.2} seat={seat:.2} \
drop={drop:+.2} terrain={} above={} spline={} swim={}",
guid.0,
// Which kind this row is: since B357 the listing is not all creatures, and a
// player row is the one a Playerbots report is about.
if net.kind == EntityKind::Player {
"player"
} else {
"unit"
},
// The display id, so a row hands straight to `WOW_CLAMP_TRACE=<display>` — the
// per-frame `clmp` readout is filtered by display and nothing else, and a census
// row that names only a guid cannot be followed up with it.
net.display_id.unwrap_or(0),
wow[0],
wow[1],
wow[2],
@@ -50,6 +50,16 @@ impl Plugin for LiveFpsPlugin {
run: std::env::var("WOW_LIVE_FPS_MOVE").as_deref() == Ok("1"),
phase: LiveFpsPhase::Waiting,
samples: Vec::new(),
cpu_samples: Vec::new(),
cpu_prev: None,
churn_samples: Vec::new(),
entities_prev: 0,
decodes_prev: 0,
load_samples: Vec::new(),
paced_samples: Vec::new(),
threads_at_start: None,
faults_at_start: None,
still_at_start: [0; 7],
cpu_at_start: None,
sys_at_start: None,
occluded_now: false,
@@ -72,6 +82,12 @@ enum LiveFpsPhase {
}
/// [`LiveFpsPlugin`] state.
/// The still-frame input counters (`benilla_world::dev_state::STILL_INPUTS_CHANGED`), now.
fn still_inputs_now() -> [u32; 7] {
let c = &benilla_world::dev_state::STILL_INPUTS_CHANGED;
std::array::from_fn(|i| c[i].load(std::sync::atomic::Ordering::Relaxed))
}
#[derive(Resource)]
struct LiveFps {
frames: usize,
@@ -80,6 +96,34 @@ struct LiveFps {
run: bool,
phase: LiveFpsPhase,
samples: Vec<f32>,
/// Process CPU per frame, ms, index-parallel with `samples` — the TAIL half of `cpu_ms`:
/// a mean under budget hides the one frame in twenty that is over it, and whether that
/// frame was CPU work or a wait is the first question about a stutter.
cpu_samples: Vec<f32>,
cpu_prev: Option<f64>,
/// Per frame, beside `cpu_samples`: entities added net of removed, mesh assets added,
/// model materials added — the three spike signatures a crowd produces (a spell kit
/// spawning, a body dressing, a first-seen material realizing). Printed for the worst
/// frames only.
churn_samples: Vec<(i32, u32, u32, u32)>,
entities_prev: usize,
decodes_prev: u32,
/// Live particles and the GPU meter's freshest reading per frame — the two "same work,
/// more of it" signatures the churn triple cannot carry.
load_samples: Vec<(u32, f32)>,
/// The PACED clock's delta per frame (`Time<Virtual>`, `frame_pace`): under vsync with
/// pipelined rendering the raw delta is mis-split into long/short pairs that sum to exact
/// refresh multiples, so `samples`' tail counts presentation jitter as frames. The pacer
/// snaps a cadenced delta to whole refreshes; a paced delta of two periods is a frame the
/// display actually skipped.
paced_samples: Vec<f32>,
/// Per-thread CPU at the window's first frame (`perf::thread_cpu_table`), keyed by pthread
/// identity — subtracted at the window's end for `thr=[name:ms/frame,…]`.
threads_at_start: Option<Vec<(usize, String, f64, f64)>>,
/// The page-fault counters at the window's first frame (`perf::process_faults`).
faults_at_start: Option<(u64, u64)>,
/// `dev_state::STILL_INPUTS_CHANGED` at the window's first frame — `noisy=` is the delta.
still_at_start: [u32; 7],
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the
/// baseline for the window's `cpu_ms`/`cpu_pct`.
cpu_at_start: Option<f64>,
@@ -114,6 +158,37 @@ type ScreenParams<'w, 's> = (
Query<'w, 's, &'static bevy::window::Monitor>,
Option<Res<'w, crate::perf::GpuMsShared>>,
Local<'s, Vec<f32>>,
Option<Res<'w, crate::perf::WgpuCensusShared>>,
// Every camera, for `views=`: the number of ACTIVE views the render app walked this frame —
// each is its own pass set (a portrait booth awake, a body pane open), and a lane that
// scales with views (cluster assignment, the view bind groups, every per-pass tracker) is
// not a scene cost until this number is known.
Query<'w, 's, &'static Camera>,
// The UI's live texture-identity runs (`ui_batches=`): one `Mesh2d` draw each in the 2D pass.
Query<'w, 's, (), With<crate::ui_pass::UiQuadBatch>>,
// Every view-visible `Mesh3d` by lane (`vis_terrain/model/liquid/other=`): the 3D passes'
// draw population. `drawn=` counts model submeshes only; the terrain is one entity per
// ADT chunk and a city horizon is over a thousand of them — a per-draw cost is not
// attributable until this split is known.
Query<
'w,
's,
(
&'static bevy::camera::visibility::ViewVisibility,
Has<MeshMaterial3d<benilla_assets::materials::TerrainMaterial>>,
Option<&'static benilla_world::model_render::ModelPart>,
Has<MeshMaterial3d<benilla_assets::materials::LiquidMaterial>>,
),
With<Mesh3d>,
>,
// Asset adds per frame, for the worst-frame annotation (`LiveFps::churn_samples`).
MessageReader<'w, 's, AssetEvent<Mesh>>,
MessageReader<'w, 's, AssetEvent<benilla_assets::materials::WowModelMaterial>>,
Res<'w, Time<bevy::time::Virtual>>,
ResMut<'w, crate::perf::MainThreadSplit>,
// The additive blend-state check (`perf::blend_check`): draws whose bound blend state
// contradicted their material this frame — `blend_mismatch=` on the line.
Option<Res<'w, crate::perf::BlendMismatchShared>>,
);
#[derive(SystemParam)]
@@ -157,7 +232,32 @@ fn drive_live_fps(
// the leg line carries gpu percentiles beside the cpu ones (absent when the meter is off).
mut screen: ScreenParams,
) {
let (monitors, gpu, gpu_samples) = (&screen.0, &screen.1, &mut screen.2);
let (monitors, gpu, gpu_samples, wgpu_census, cameras, ui_batches, vis_meshes) = (
&screen.0,
&screen.1,
&mut screen.2,
&screen.3,
&screen.4,
&screen.5,
&screen.6,
);
let (mesh_events, mat_events) = (&mut screen.7, &mut screen.8);
let paced_ms = screen.9.delta_secs() * 1000.0;
let main_split = &mut screen.10;
let blend_mismatch = screen
.11
.as_ref()
.map_or(0, |m| m.0.load(std::sync::atomic::Ordering::Relaxed));
// Read every frame (a reader that only reads inside the window would report the whole
// backlog on its first sampled frame).
let mesh_added = mesh_events
.read()
.filter(|e| matches!(e, AssetEvent::Added { .. }))
.count() as u32;
let mat_added = mat_events
.read()
.filter(|e| matches!(e, AssetEvent::Added { .. }))
.count() as u32;
// Drain every frame so the state is current whichever phase we're in — the window can be
// occluded before sampling ever starts (a detached launch spawns behind whatever is open).
for o in occlusions.read() {
@@ -220,6 +320,10 @@ fn drive_live_fps(
LiveFpsPhase::Sampling => {
if probe.samples.is_empty() {
probe.cpu_at_start = crate::perf::process_cpu_secs();
probe.threads_at_start = crate::perf::thread_cpu_table();
probe.faults_at_start = crate::perf::process_faults();
probe.still_at_start = still_inputs_now();
main_split.restart();
probe.sys_at_start = crate::perf::system_cpu_ticks();
// The churn census restarts with the window — warmup noise (streaming, shader
// warms) would otherwise read as steady-state ratchets.
@@ -231,12 +335,38 @@ fn drive_live_fps(
}
let ms = time.delta_secs() * 1000.0;
probe.samples.push(ms);
let cpu_now = crate::perf::process_cpu_secs();
let cpu_ms = match (probe.cpu_prev, cpu_now) {
(Some(a), Some(b)) => ((b - a) * 1000.0) as f32,
_ => 0.0,
};
probe.cpu_prev = cpu_now;
probe.cpu_samples.push(cpu_ms);
let n_entities = entities.iter().count();
let d_entities = if probe.churn_samples.is_empty() {
0
} else {
n_entities as i32 - probe.entities_prev as i32
};
probe.entities_prev = n_entities;
let decodes = crate::sound::kit_decodes();
let d_decodes = decodes.saturating_sub(probe.decodes_prev);
probe.decodes_prev = decodes;
probe
.churn_samples
.push((d_entities, mesh_added, mat_added, d_decodes));
let mut gpu_now = 0.0;
if let Some(gpu) = gpu {
let ns = gpu.0.load(std::sync::atomic::Ordering::Relaxed);
if ns > 0 {
gpu_samples.push(ns as f32 / 1.0e6);
gpu_now = ns as f32 / 1.0e6;
gpu_samples.push(gpu_now);
}
}
probe
.load_samples
.push((census.live_particles() as u32, gpu_now));
probe.paced_samples.push(paced_ms);
if probe.samples.len() < probe.frames {
return;
}
@@ -301,8 +431,32 @@ fn drive_live_fps(
let mut g = std::mem::take(&mut **gpu_samples);
g.sort_by(f32::total_cmp);
let gat = |q: f32| g[(((g.len() - 1) as f32) * q).round() as usize];
// …and the wgpu resource census beside it (`perf::gpu::WgpuCensus`): the
// residency term of the pass-encode CPU, sampled at the window's end.
let census = wgpu_census
.as_ref()
.map(|c| {
let ld = |a: &std::sync::atomic::AtomicU64| {
a.load(std::sync::atomic::Ordering::Relaxed)
};
let fx = &benilla_world::particles::render::EFFECT_DRAW_STATS;
format!(
" wgpu_bufs={} wgpu_texs={} wgpu_bgs={} draws=o{}/t{}/s{}/ui{} \
fx={}/{}",
ld(&c.0.buffers),
ld(&c.0.textures),
ld(&c.0.bind_groups),
ld(&c.0.draws_opaque),
ld(&c.0.draws_transparent),
ld(&c.0.draws_shadow),
ld(&c.0.draws_ui),
fx[0].load(std::sync::atomic::Ordering::Relaxed),
fx[1].load(std::sync::atomic::Ordering::Relaxed)
)
})
.unwrap_or_default();
format!(
" gpu_p50={:.2} gpu_p99={:.2} gpu_max={:.2}",
" gpu_p50={:.2} gpu_p99={:.2} gpu_max={:.2}{census}",
gat(0.50),
gat(0.99),
g[g.len() - 1]
@@ -310,12 +464,110 @@ fn drive_live_fps(
};
// CPU cost per frame across every thread — the load-robust half of the measurement
// (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %.
let v_len = v.len();
let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) {
(Some(t0), Some(t1)) => {
let per_frame_ms = (t1 - t0) * 1000.0 / v.len() as f64;
// The tail: CPU percentiles over the same frames, how many frames ran over
// the 60 Hz budget, and the worst six as `index:frame_ms/cpu_ms` — a frame
// whose cpu is near its length was WORK; one far under it was a WAIT (GPU,
// present, a lock).
let mut c: Vec<f32> = probe.cpu_samples.clone();
c.sort_by(f32::total_cmp);
let cat = |q: f32| c[(((c.len() - 1) as f32) * q).round() as usize];
let over = probe.samples.iter().filter(|&&f| f > 16.9).count();
// Real skipped frames: paced deltas of two refreshes or more.
let period = 1000.0 / 60.0;
let dropped = probe
.paced_samples
.iter()
.filter(|&&f| f > 1.5 * period)
.count();
let paced_max = probe.paced_samples.iter().copied().fold(0.0f32, f32::max);
let mut worst: Vec<usize> = (0..probe.samples.len()).collect();
worst.sort_by(|&a, &b| probe.samples[b].total_cmp(&probe.samples[a]));
let worst = worst
.iter()
.take(6)
.map(|&i| {
let (de, dm, dmat, dsnd) =
probe.churn_samples.get(i).copied().unwrap_or((0, 0, 0, 0));
let (np, g) = probe.load_samples.get(i).copied().unwrap_or((0, 0.0));
format!(
"{i}:{:.1}/{:.1}(e{de:+},m+{dm},mat+{dmat},snd+{dsnd},p{np},g{g:.1})",
probe.samples[i],
probe.cpu_samples.get(i).copied().unwrap_or(0.0)
)
})
.collect::<Vec<_>>()
.join(",");
// Per thread, ms per frame over the window — the split that says which
// thread a tax landed on; threads sharing a name (a pool) are summed.
let threads = match (&probe.threads_at_start, crate::perf::thread_cpu_table()) {
(Some(t0), Some(t1)) => {
let start: std::collections::HashMap<usize, (f64, f64)> =
t0.iter().map(|(k, _, u, s)| (*k, (*u, *s))).collect();
let mut by_name: std::collections::HashMap<String, (f64, f64)> =
std::collections::HashMap::new();
for (k, name, u1, s1) in &t1 {
let (u0, s0) = start.get(k).copied().unwrap_or((0.0, 0.0));
let name = match name.rfind(" (") {
Some(i) if name.ends_with(')') => name[..i].to_string(),
_ => name.clone(),
};
let e = by_name.entry(name).or_default();
e.0 += u1 - u0;
e.1 += s1 - s0;
}
let mut v: Vec<(String, (f64, f64))> = by_name.into_iter().collect();
v.sort_by(|a, b| (b.1 .0 + b.1 .1).total_cmp(&(a.1 .0 + a.1 .1)));
let per = |secs: f64| secs * 1000.0 / v_len as f64;
// `name:user+sys`, ms per frame.
let list = v
.iter()
.take(8)
.map(|(n, (u, s))| format!("{n}:{:.2}+{:.2}", per(*u), per(*s)))
.collect::<Vec<_>>()
.join(",");
let inside = main_split
.inside_ms()
.map(|m| format!(" main_in={m:.2}"))
.unwrap_or_default();
let now_still = still_inputs_now();
let noisy: Vec<u32> = now_still
.iter()
.zip(probe.still_at_start)
.map(|(n, s)| n.wrapping_sub(s))
.collect();
// Page faults per frame beside the split: `sys` time with no
// syscall under it is the kernel zero-filling pages the allocator
// gave back and asked for again.
let faults =
match (probe.faults_at_start, crate::perf::process_faults()) {
(Some((mi0, ma0)), Some((mi1, ma1))) => format!(
" faults={:.0}/{:.1} noisy=[cam:{},dbg:{},view:{},win:{},claim:{},portals:{},flips:{}]",
(mi1 - mi0) as f64 / v_len as f64,
(ma1 - ma0) as f64 / v_len as f64,
noisy[0],
noisy[1],
noisy[2],
noisy[3],
noisy[4],
noisy[5],
noisy[6],
),
_ => String::new(),
};
format!(" thr=[{list}]{inside}{faults}")
}
_ => String::new(),
};
format!(
" cpu_ms={per_frame_ms:.2} cpu_pct={:.0}",
per_frame_ms / mean as f64 * 100.0
" cpu_ms={per_frame_ms:.2} cpu_pct={:.0} cpu_p95={:.2} cpu_p99={:.2} \
frames_over={over} dropped={dropped} paced_max={paced_max:.1} worst=[{worst}]{threads}",
per_frame_ms / mean as f64 * 100.0,
cat(0.95),
cat(0.99),
)
}
_ => String::new(),
@@ -399,9 +651,64 @@ fn drive_live_fps(
)
})
.unwrap_or_default();
let tiles = seen
.tiles
.map(|(f, w)| format!(" tiles={f}/{w}"))
.unwrap_or_default();
if !seen.orphans.is_empty() {
let top: Vec<String> = seen
.orphans
.iter()
.take(8)
.map(|(id, label, n)| format!("#{id} {label} x{n}"))
.collect();
info!(
"FPS_PROBE orphan placed parts: {} across {} placements — {}",
seen.orphan_parts,
seen.orphans.len(),
top.join("; ")
);
}
let residency_line = format!(
" mats={} meshes={} images={} uv={} tint={}",
seen.mats, seen.meshes, seen.images, seen.uv_anims, seen.tint_anims,
" mats={} mats_parked={} meshes={} images={} uv={} tint={} views={} ui_batches={}{vis}{tiles} orphan_parts={orphans} stacked_parts={stacked} blend_mismatch={blend_mismatch}",
seen.mats,
seen.mats_parked,
seen.meshes,
seen.images,
seen.uv_anims,
seen.tint_anims,
cameras.iter().filter(|c| c.is_active).count(),
ui_batches.iter().count(),
vis = {
let mut n = [0usize; 4];
// Of the model parts, how many draw in the transparent pass (M2 `Blend`
// batches) — the sorted phase whose draws never batch across materials.
let mut blended = 0usize;
for (vv, terrain, model, liquid) in vis_meshes.iter() {
if !vv.get() {
continue;
}
let k = if terrain {
0
} else if let Some(part) = model {
if part.blend == benilla_formats::ModelBlend::Blend {
blended += 1;
}
1
} else if liquid {
2
} else {
3
};
n[k] += 1;
}
format!(
" vis_terrain={} vis_model={}(blend {blended}) vis_liquid={} vis_other={}",
n[0], n[1], n[2], n[3]
)
},
orphans = seen.orphan_parts,
stacked = seen.stacked_parts,
);
println!(
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display}{gpu_line} occluded_frames={}{at_pin}{cam_pose}{gate}{sky}{ribbons}{culled}",
@@ -475,8 +782,21 @@ fn print_vis_census(seen: &benilla_world::world_census::CensusReport) {
.map(|(name, vis, gated)| format!("{name}={vis}/gated{gated}"))
.collect::<Vec<_>>()
.join(" ");
let resident = seen
.kinds
.iter()
.zip(seen.resident.iter())
.map(|((name, _, _), n)| format!("{name}={n}"))
.collect::<Vec<_>>()
.join(" ");
let why = seen
.why
.iter()
.map(|(w, n)| format!("{w}={n}"))
.collect::<Vec<_>>()
.join(" ");
println!(
"VIS_CENSUS visible-submeshes {line} | tagged={} hidden={} exempt={} no_aabb={}",
"VIS_CENSUS visible-submeshes {line} | resident {resident} | why {why} | tagged={} hidden={} exempt={} no_aabb={}",
seen.tagged, seen.hidden, seen.exempt, seen.no_aabb
);
// The escapees always print: a tagged, bounded object the cull left un-hidden is a defect by
@@ -138,7 +138,11 @@ fn fire_particle_census(
let lane = if world_layer { "world" } else { "booth" };
let c = fade.map_or_else(|| e.anchor_world(), |f| f.center);
format!(
" depth={d:.1} drawn={drawn} lane={lane} gated={} at=({:.0},{:.0},{:.0})",
// `has_fade`, not `gated`: this column prints whether the emitter carries an
// `EmitterFade`, and calling it `gated` made it read as the draw-set verdict —
// which is `drawn` (its inverse), right beside it. A tile-emitter reading was
// taken backwards off the old label before it was renamed (decision 2046).
" depth={d:.1} drawn={drawn} lane={lane} has_fade={} at=({:.0},{:.0},{:.0})",
fade.is_some(),
c.x,
c.y,
+235 -28
View File
@@ -33,6 +33,15 @@ pub(super) struct Scenario {
/// uses), so the capture exercises the real feed → VM → extract → render chain end to end.
#[derive(Clone, Copy, PartialEq)]
pub(super) enum UiFixture {
/// **The player UI, with no window opened.** `ui-unitframes`, `ui-combopoints` and
/// `ui-actionbar` photograph chrome that is simply *there* once the UI loads and the synthetic
/// unit snapshot lands (`ui_script::demo_unit_feed`) — there is nothing to seed and nothing to
/// click. They used to say `ui: None`, which made the field mean two things at once ("seed
/// this window" *and* "does this capture include the UI at all") and left the second
/// unstateable: they were UI captures that the table said were world captures, kept working
/// only by `WOW_CAPTURE_UI=1` on the command line. One field, one meaning — so
/// [`ui_opted_in`] can just ask `ui.is_some()`.
Bare,
Merchant,
Gossip,
Quest,
@@ -55,6 +64,30 @@ pub(super) enum UiFixture {
QuestLog,
Loot,
Bag,
/// **The cooldown sweep, at sixteen phases in one still** (B379).
///
/// The backpack filled slot for slot, each slot's `GetContainerItemCooldown` triple parked at
/// its own fraction of one very long cooldown — so the sixteen `…Cooldown` model panes
/// (`CooldownFrameTemplate`, the reference's own `UI-Cooldown-Indicator.m2` through the tile
/// lane, decision 2019) each hold a different point of the 1000 ms sweep and the window reads
/// left-to-right, top-to-bottom as a filmstrip of it. Game slot 1 renders TOP-LEFT
/// (`ContainerFrame_GenerateFrame` numbers backwards — see `seed_bag_window`), so ascending
/// slot is reading order.
///
/// **The clock is pinned so the phases are, too.** `GetTime()` is the VM's own accumulating
/// session clock (`__benilla_now`), and the frame count to the shutter varies run to run — so
/// an absolute start would put the sweep somewhere different every capture and the baseline
/// could never diff. The fixture parks `__benilla_now` at a large value and gives every
/// slot a very long cooldown, which makes the settle window's few seconds worth
/// `~5e-4` of one phase step.
Cooldown,
/// **The cooldown filmstrip with the pet bar's autocast SHINE up beside it** (B379).
///
/// `UI-AutoCastButton.m2` is four additive spline emitters and no render batch at all — 300
/// live golden particles (`GlowStar.blp`, born `(0.98, 0.87, 0.19)`) circling the button. It
/// shares the tile atlas with every other `<Model>` pane, and a particle is a world quad in
/// ATLAS space: this scenario is the one that asks whether it stays inside its own cell.
CooldownShine,
/// The bag window with the GameTooltip forced open over a known slot — the tooltip look-pass
/// instrument (crisp border, tiled edges, tinted plate, quality-coloured item name, snug size).
Tooltip,
@@ -70,6 +103,15 @@ pub(super) enum UiFixture {
/// render): slot icons, the attribute/resistance panes with buff coloring, melee + ranged
/// blocks, the ammo count, the level line.
Character,
/// **The loading screen, held up, with the tip of the day on it** (decision 2083).
///
/// Not a window: it raises the world-entry loading screen — per-map backdrop, progress bar,
/// and the `TipEdge::Pick` that chooses a `GameTips.dbc` row — and pins it so the harness has
/// a still frame to settle on. Every other screen in the client can be photographed by
/// standing in front of it; this one is up for a second on a path nobody can pause, which is
/// exactly why its tip could ship dead, log `tip 0 of 74` twice in a live smoke run, and be
/// caught by nothing at all.
LoadingTip,
/// The shared **StaticPopup** plate, shown as the group-invite dialog (`PARTY_INVITE`).
///
/// Every other StaticPopup customer in the tree raises its dialog over another window's opaque
@@ -875,7 +917,27 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Bag),
},
// The GameTooltip forced open over a seeded bag slot (a green-quality item). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-tooltip`.
// `WOW_CAPTURE=ui-tooltip`.
// The cooldown sweep as a filmstrip: sixteen bag slots, sixteen phases, one deterministic
// still (B379). Run with `WOW_CAPTURE=ui-cooldown`.
Scenario {
name: "ui-cooldown",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::Cooldown),
},
// The same filmstrip with the autocast shine sharing the atlas — the cell-bleed instrument.
// Run with `WOW_CAPTURE=ui-cooldown-shine`.
Scenario {
name: "ui-cooldown-shine",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::CooldownShine),
},
Scenario {
name: "ui-tooltip",
map: Some(MAP_AZEROTH),
@@ -885,7 +947,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Tooltip),
},
// The world-mouseover tooltip at the DEFAULT corner (screen bottom-right, −13/+70) over a
// seeded hostile wolf. Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-tooltip-world`.
// seeded hostile wolf. Run with `WOW_CAPTURE=ui-tooltip-world`.
Scenario {
name: "ui-tooltip-world",
map: Some(MAP_AZEROTH),
@@ -895,7 +957,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::TooltipWorld),
},
// The character window's paper doll over a fully-seeded synthetic self player. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-char`.
// `WOW_CAPTURE=ui-char`.
Scenario {
name: "ui-char",
map: Some(MAP_AZEROTH),
@@ -906,29 +968,29 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
// The player + target unit frames (no window fixture — the frames come from `demo_unit_feed`,
// which seeds synthetic "player"/"target" snapshots whenever WOW_CAPTURE_UI=1). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-unitframes`.
// `WOW_CAPTURE=ui-unitframes`.
Scenario {
name: "ui-unitframes",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: None,
ui: Some(UiFixture::Bare),
},
// The combo-point dots at the target frame's top-right (decisions 0869/0875). `demo_unit_feed`
// seeds a ROGUE with four points banked on the selected wolf for this scenario only — the demo
// player is a warrior everywhere else, and a warrior authentically lights no dot. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-combopoints`.
// `WOW_CAPTURE=ui-combopoints`.
Scenario {
name: "ui-combopoints",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: None,
ui: Some(UiFixture::Bare),
},
// The group-invite dialog over open world — the shared StaticPopup plate's only look-pass.
// Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-partyinvite`.
// Run with `WOW_CAPTURE=ui-partyinvite`.
Scenario {
name: "ui-partyinvite",
map: Some(MAP_AZEROTH),
@@ -941,19 +1003,22 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// WOW_CAPTURE_UI=1, and `demo_unit_feed` seeds the action slots + player XP). The bar is 1024
// wide + 128px end caps, so this fixture takes a WIDER, shorter window (see main.rs's per-capture
// sizing) — the default 640px UI window would crop it. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-actionbar`.
// `WOW_CAPTURE=ui-actionbar`.
Scenario {
name: "ui-actionbar",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: None,
ui: Some(UiFixture::Bare),
},
// The V-key nameplate over a synthetic Timber Wolf, framed like the reference screenshot
// (an eye-height look at a wolf ~8 yd off, Northshire ground). Plates draw through the
// UiQuads overlay, which renders in every capture — no WOW_CAPTURE_UI needed. Run with
// `WOW_CAPTURE=vplates` (main.rs sizes this window 1024×768 — the 1:1 gx window).
// (an eye-height look at a wolf ~8 yd off, Northshire ground). **Plates are real widgets since
// 2148**, so they need the UI VM and a `WorldFrame` to hang off — which this scenario's own
// `ui:` fixture supplies (it is what makes `run_mode::capture_ui_opted_in` true). The older
// note here said the opposite ("plates draw through the UiQuads overlay… no WOW_CAPTURE_UI
// needed"), which was the painter's truth and stopped being true the day they became frames.
// Run with `WOW_CAPTURE=vplates` (main.rs sizes this window 1024×768 — the 1:1 gx window).
Scenario {
name: "vplates",
map: Some(MAP_AZEROTH),
@@ -964,7 +1029,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
// The fullscreen world map (decision 0203 phase 2), forced open at the world sheet. The frame's
// 1024×768 chrome needs the taller window main.rs gives the 1:1 gx captures. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-worldmap`.
// `WOW_CAPTURE=ui-worldmap`.
Scenario {
name: "ui-worldmap",
map: Some(MAP_AZEROTH),
@@ -973,7 +1038,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::WorldMap),
},
// The spellbook over a seeded mage book. Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-spellbook`.
// The spellbook over a seeded mage book. Run with `WOW_CAPTURE=ui-spellbook`.
Scenario {
name: "ui-spellbook",
map: Some(MAP_AZEROTH),
@@ -983,7 +1048,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::SpellBook),
},
// The macro window over a seeded macro set. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-macro`.
// `WOW_CAPTURE=ui-macro`.
Scenario {
name: "ui-macro",
map: Some(MAP_AZEROTH),
@@ -993,7 +1058,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Macro),
},
// The same window with the name/icon popup open. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-macro-popup`.
// `WOW_CAPTURE=ui-macro-popup`.
Scenario {
name: "ui-macro-popup",
map: Some(MAP_AZEROTH),
@@ -1003,7 +1068,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::MacroPopup),
},
// The chat edit box open with a typed draft over seeded lines. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-chatedit`.
// `WOW_CAPTURE=ui-chatedit`.
Scenario {
name: "ui-chatedit",
map: Some(MAP_AZEROTH),
@@ -1014,7 +1079,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
// The hovered chat dock — the tab plate + its ADD highlight over the world, which is the
// pair the UI-over-world composite space decides. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-chat-tabhover`.
// `WOW_CAPTURE=ui-chat-tabhover`.
Scenario {
name: "ui-chat-tabhover",
map: Some(MAP_AZEROTH),
@@ -1024,7 +1089,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::ChatTabHover),
},
// The social pane, for the stray-dropdown regression (B264, decision 1298). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-social`.
// `WOW_CAPTURE=ui-social`.
Scenario {
name: "ui-social",
map: Some(MAP_AZEROTH),
@@ -1033,8 +1098,19 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::Social),
},
// The world-entry loading screen, held up with its tip of the day. Run with
// `WOW_CAPTURE=loading-tip`. The backdrop is whatever `Map.dbc` → `LoadingScreens.dbc` gives
// this scenario's map, so it photographs the art chain and the bar as well as the tip.
Scenario {
name: "loading-tip",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::LoadingTip),
},
// The era Options window over the ground scene. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options`.
// `WOW_CAPTURE=ui-options`.
Scenario {
name: "ui-options",
map: Some(MAP_AZEROTH),
@@ -1044,7 +1120,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Options),
},
// The same window on the AUDIO page (0957). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-audio`.
// `WOW_CAPTURE=ui-options-audio`.
Scenario {
name: "ui-options-audio",
map: Some(MAP_AZEROTH),
@@ -1054,7 +1130,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::OptionsAudio),
},
// The same window on the GRAPHICS page (0959). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-graphics`.
// `WOW_CAPTURE=ui-options-graphics`.
Scenario {
name: "ui-options-graphics",
map: Some(MAP_AZEROTH),
@@ -1064,7 +1140,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::OptionsGraphics),
},
// The same window on the CHAT page (1589). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-chat`.
// `WOW_CAPTURE=ui-options-chat`.
Scenario {
name: "ui-options-chat",
map: Some(MAP_AZEROTH),
@@ -1074,7 +1150,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::OptionsChat),
},
// The colour picker, wheel and all (1592). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-color-picker`.
// `WOW_CAPTURE=ui-color-picker`.
Scenario {
name: "ui-color-picker",
map: Some(MAP_AZEROTH),
@@ -1084,7 +1160,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::ColorPicker),
},
// The Controls page with the Camera Following Style dropdown OPEN (0992, re-seated 1649). Run
// with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-dropdown`.
// with `WOW_CAPTURE=ui-options-dropdown`.
Scenario {
name: "ui-options-dropdown",
map: Some(MAP_AZEROTH),
@@ -1094,7 +1170,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::OptionsDropdownList),
},
// The options window's Keybindings page, Movement expanded (1008). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-keybindings`.
// `WOW_CAPTURE=ui-keybindings`.
Scenario {
name: "ui-keybindings",
map: Some(MAP_AZEROTH),
@@ -1104,7 +1180,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::KeyBindings),
},
// The same window MID-SEARCH (0984): the "volume" results view. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-search`.
// `WOW_CAPTURE=ui-options-search`.
Scenario {
name: "ui-options-search",
map: Some(MAP_AZEROTH),
@@ -1126,6 +1202,19 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
];
/// The `name-close` instrument's subject: the `name-water` wolf's overhead NAME, orbited at an
/// arbitrary distance. **World text is the only consumer that draws the glyph sheet at anything
/// but 1:1** — the 2-D path rasterizes at the size it draws, texel for pixel, by construction — so
/// a defect in how a glyph CELL is sampled can only appear in a magnified name, and nothing
/// photographed one: `name-water`'s 25 yd MINIFIES the same glyphs, which hides exactly this.
///
/// Not a golden scenario (its output is the knobs', not the name's), same as `fxview`/`vista`:
/// `WOW_NAME_DIST` (yd from the name, default 4), `WOW_NAME_AZ` (compass bearing the eye stands
/// on, degrees, 0 = +X), `WOW_NAME_EL` (eye elevation above the name, degrees), `WOW_NAME_H` (the
/// name's height above the unit's feet, yd — the anchor this aims at). The camera looks straight
/// at the name, so the shot is centred and the magnification is `WOW_NAME_DIST` alone.
pub(super) const NAME_CLOSE_AT: [f32; 3] = super::fixtures::NAME_WATER_POS;
/// The Deeprun Tram undersea tube — see the `tram-undersea` scenario. Raw WoW coords; the Subway
/// WMO is the map's global `MODF` at the origin with identity rotation, so these are also its
/// model-space coords.
@@ -1140,3 +1229,121 @@ pub(super) const TRAM_LOOK: [f32; 3] = [-2.44, -1400.0, -118.0];
/// are sky.
pub(super) const SCAR_EYE: [f32; 3] = [-11892.7, -2647.1, 20.0];
pub(super) const SCAR_LOOK: [f32; 3] = [-11792.7, -2647.1, 66.6];
/// **Does the scenario named by `$WOW_CAPTURE` declare a UI fixture?** — i.e. is this a capture
/// whose *subject* is a window.
///
/// `WOW_CAPTURE_UI=1` exists so that world baselines stay UI-free: a capture regression-tests the
/// world render, and the player UI painted over it would make every diff a UI diff. That default
/// is right. What was wrong is that it also gated the scenarios that ARE the UI — a `ui-*`
/// scenario declares `ui: Some(..)` in the table above, which is the harness's own statement that
/// the window is the point, and then needed the env var repeated on the command line to mean it.
///
/// **Forgetting it did not fail.** The run brought up the world with no in-game UI, the fixture's
/// seed hit a nil global (`ToggleQuestLog`), logged ONE `warn!` into ~100 lines of pipeline
/// chatter, wrote a perfectly valid PNG of a UI-less world, and exited **0**. That is an
/// instrument that manufactures a confident false negative — the shape `method.md` §6 spends a
/// paragraph on — and it cost a session a wasted build and nearly a wrong conclusion about a fix
/// that was in fact working.
///
/// So a `ui:` fixture now opts the UI in on its own account. World scenarios (`ui: None`) are
/// untouched and stay pristine; `WOW_CAPTURE_UI=1` still works and still means what it meant, for
/// the case it was actually for — opting the UI into a WORLD scenario's shot.
/// Find a scenario by name across **both** tables — the blessed sweep [`SCENARIOS`] and
/// [`ON_DEMAND`], exactly as the harness's own `WOW_CAPTURE=` resolution does (decision 0632: only
/// the sweep is narrowed, every viewpoint stays capturable by name). Every `ui-*` fixture lives in
/// `ON_DEMAND`, so a lookup that searched only `SCENARIOS` would answer "not a UI scenario" for
/// precisely the scenarios this predicate exists to serve — which is what the test below caught
/// on the first run of it.
pub(super) fn by_name(name: &str) -> Option<&'static Scenario> {
SCENARIOS
.iter()
.chain(ON_DEMAND.iter())
.find(|s| s.name == name)
}
fn scenario_declares_ui() -> bool {
std::env::var("WOW_CAPTURE")
.ok()
.and_then(|name| by_name(&name))
.is_some_and(|s| s.ui.is_some())
}
/// **The one predicate**: is the player UI opted into this capture? Either the scenario declares a
/// `ui:` fixture, or `WOW_CAPTURE_UI=1` asks for it over a world scene.
///
/// It is one function because it has three consumers that MUST agree, and the cost of them
/// disagreeing is a capture that looks plausible and isn't: the UI load itself
/// (`ui_script::lifecycle::ui_wanted`), the synthetic `"player"`/`"target"` snapshot that
/// populates the frames in a server-less run (`ui_script::capture_ui_active` →
/// `demo_unit_feed`), and the window SIZE the scenario is denominated in (`lib.rs`). Load the UI
/// without the demo feed and `ui-unitframes` photographs empty frames; load it without the size
/// and every UI baseline is denominated in the world window. Neither failure looks like a failure.
pub(crate) fn ui_opted_in() -> bool {
std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") || scenario_declares_ui()
}
#[cfg(test)]
mod ui_opt_in_tests {
use super::*;
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
/// The footgun this predicate exists to remove: a scenario whose subject is a window opts the
/// UI in **without** `WOW_CAPTURE_UI=1`, while a world scenario still does not. Before this,
/// `WOW_CAPTURE=ui-questlog` alone brought up a UI-less world, failed its seed on a nil
/// global, wrote a valid PNG and exited 0.
#[test]
fn a_ui_scenario_opts_the_ui_in_and_a_world_scenario_does_not() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _ui = EnvGuard::unset("WOW_CAPTURE_UI");
let _c = EnvGuard::set("WOW_CAPTURE", "ui-questlog");
assert!(
ui_opted_in(),
"a `ui:` scenario is a UI capture by construction"
);
// The one that made the concept honest: it opens no window, so it used to say
// `ui: None` and was a UI capture the table called a world capture.
let _c = EnvGuard::set("WOW_CAPTURE", "ui-unitframes");
assert!(ui_opted_in(), "…including the fixtureless `Bare` ones");
// A world scenario's baseline regression-tests the WORLD render and must stay UI-free.
let world = SCENARIOS
.iter()
.chain(ON_DEMAND.iter())
.find(|s| s.ui.is_none())
.expect("the tables have world scenarios");
let _c = EnvGuard::set("WOW_CAPTURE", world.name);
assert!(
!ui_opted_in(),
"world scenario {} must stay pristine",
world.name
);
// …unless the env var asks for it over a world scene, which is what it was always for.
let _on = EnvGuard::set("WOW_CAPTURE_UI", "1");
assert!(ui_opted_in(), "the env var still opts a world scene in");
}
/// Every `ui-*`-named scenario really does declare a fixture — otherwise it would silently be
/// a world capture wearing a UI name, and the predicate above would not save it.
#[test]
fn every_ui_named_scenario_declares_a_fixture() {
for s in SCENARIOS
.iter()
.chain(ON_DEMAND.iter())
.filter(|s| s.name.starts_with("ui-"))
{
assert!(
s.ui.is_some(),
"scenario {} is named ui-* but declares no `ui:` fixture — it would be treated as \
a world capture and photographed with no player UI. If it opens no window, that \
is what `UiFixture::Bare` is for.",
s.name
);
}
}
}
+164 -34
View File
@@ -33,6 +33,7 @@ use bevy::prelude::*;
use crate::char_select::{ClientState, Roster};
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
use crate::net::{CharActionResultMessage, CharPick, CharRequest};
use crate::portrait::{CreateLook, GlueLook, GluePreview};
use crate::sound::GlueSound;
@@ -75,15 +76,13 @@ impl Plugin for CharCreatePlugin {
refresh::refresh_dynamic,
refresh_name_box,
refresh::refresh_hover,
crate::glue::art_swaps,
crate::glue::glue_button_visuals,
refresh::scroll_info,
refresh::scroll_drive,
refresh::scroll_visuals,
create_result,
crate::glue::sync_outlines,
)
.chain()
.before(crate::glue::GlueVisuals)
.run_if(in_state(ClientState::CharCreate))
.after(benilla_world::schedule::WorldStage::Net),
);
@@ -580,6 +579,7 @@ fn create_result(
mut roster: ResMut<Roster>,
mut next: ResMut<NextState<ClientState>>,
mut status: Query<&mut Text, With<parts::StatusLine>>,
strings: Res<GlueStrings>,
) {
for msg in msgs.read() {
if msg.action != CharAction::Create {
@@ -589,7 +589,7 @@ fn create_result(
info!(
"char create: result {:#04x} — {}",
msg.code,
char_result_text(msg.code)
char_result_text(&strings, msg.code)
);
if msg.code == benilla_protocol::messages::CHAR_CREATE_SUCCESS {
// The fresh roster already arrived (`net::io` re-enumerates and emits it BEFORE the
@@ -599,44 +599,174 @@ fn create_result(
roster.note_created(sel.name.text.clone());
next.set(ClientState::CharSelect);
} else if let Ok(mut text) = status.single_mut() {
text.0 = char_result_text(msg.code).to_string();
text.0 = char_result_text(&strings, msg.code).to_string();
}
}
}
/// Map a `SMSG_CHAR_CREATE` result byte to its 1.12 GlueStrings text (a frozen-fact table, extracted
/// verbatim from `Interface\GlueXML\GlueStrings.lua`; the codes are the vmangos `ResponseCodes`
/// enum, `CHAR_CREATE_SUCCESS = 0x2E` anchor). Unknown codes fall back to the generic error.
fn char_result_text(code: u8) -> &'static str {
match code {
0x2E => "Character created",
0x2F => "Error creating character",
0x30 => "Character creation failed",
0x31 => "That name is unavailable",
0x32 => "Creation of that race and/or class is currently disabled.",
0x33 => "You cannot have both a Horde and an Alliance character on the same PvP server",
0x34 => "You already have the maximum number of characters allowed on this realm.",
0x35 => "You already have the maximum number of characters allowed on this account.",
0x36 => "This server is currently queued and new character creation is temporarily disabled.",
0x37 => "Only players who already have characters on this realm are currently allowed to create characters.",
0x45 => "Enter a name for your character",
0x46 => "Names must be at least 2 characters",
0x47 => "Names must be no more than 12 characters",
0x48 => "Names can only contain letters",
0x49 => "Names must contain only one language",
0x4A => "That name contains profanity",
0x4B => "That name is unavailable",
0x4C => "You cannot use an apostrophe as the first or last character of your name",
0x4D => "You can only have one apostrophe",
0x4E => "You cannot use the same letter three times consecutively",
0x4F => "You cannot use a space as the first or last character of your name",
0x50 => "You cannot use consecutive spaces in a name",
_ => "Invalid character name",
}
/// A `SMSG_CHAR_CREATE` result byte → its **GlueStrings key**, resolved off the player's own
/// `GlueStrings.lua` with a built-in caption as the fallback — `login::world_refusal_text`'s shape,
/// and the reference's own (a key first, a literal only when the chain lacks it).
///
/// The codes are vmangos's `ResponseCodes`, anchored at `CHAR_CREATE_SUCCESS = 0x2E`; every key
/// below was derived by matching our old text against the shipped file rather than by name, then
/// corrected where the *semantics* disagreed with the match. Three things that hiding behind
/// literals had concealed (decision 2045):
///
/// - **`0x36` was missing a sentence.** Our copy of `CHAR_CREATE_SERVER_QUEUE` stopped at
/// "…temporarily disabled." where the shipped string continues "Please try again during off peak
/// hours." A re-typed string is a string nobody diffed.
/// - **`0x50` was invented.** "You cannot use consecutive spaces in a name" has no 1.12
/// counterpart — there is no `CHAR_NAME_CONSECUTIVE_SPACES` in this chain — so it takes the
/// default arm, `CHAR_CREATE_INVALID_NAME`, exactly as any code the table does not name does.
/// Composing a better sentence for a code the client has no string for is what 2035 was about.
/// - **`0x4B` named the wrong key.** It resolved `CHAR_CREATE_NAME_IN_USE` because that key's text
/// *equals* `CHAR_NAME_RESERVED`'s in enUS — "That name is unavailable" — but the code is the
/// reserved-name one. Identical text, different key: a locale that words them apart would have
/// shown the wrong sentence, and no amount of text comparison could ever have found it.
fn char_result_text<'a>(strings: &'a GlueStrings, code: u8) -> &'a str {
let (key, fallback): (&str, &'a str) = match code {
0x2E => ("CHAR_CREATE_SUCCESS", "Character created"),
0x2F => ("CHAR_CREATE_ERROR", "Error creating character"),
0x30 => ("CHAR_CREATE_FAILED", "Character creation failed"),
0x31 => ("CHAR_CREATE_NAME_IN_USE", "That name is unavailable"),
0x32 => (
"CHAR_CREATE_DISABLED",
"Creation of that race and/or class is currently disabled.",
),
0x33 => (
"CHAR_CREATE_PVP_TEAMS_VIOLATION",
"You cannot have both a Horde and an Alliance character on the same PvP server",
),
0x34 => (
"CHAR_CREATE_SERVER_LIMIT",
"You already have the maximum number of characters allowed on this realm.",
),
0x35 => (
"CHAR_CREATE_ACCOUNT_LIMIT",
"You already have the maximum number of characters allowed on this account.",
),
0x36 => (
"CHAR_CREATE_SERVER_QUEUE",
"This server is currently queued and new character creation is temporarily disabled. \
Please try again during off peak hours.",
),
0x37 => (
"CHAR_CREATE_ONLY_EXISTING",
"Only players who already have characters on this realm are currently allowed to \
create characters.",
),
0x45 => ("CHAR_NAME_NO_NAME", "Enter a name for your character"),
0x46 => ("CHAR_NAME_TOO_SHORT", "Names must be at least 2 characters"),
0x47 => (
"CHAR_NAME_TOO_LONG",
"Names must be no more than 12 characters",
),
0x48 => (
"CHAR_NAME_INVALID_CHARACTER",
"Names can only contain letters",
),
0x49 => (
"CHAR_NAME_MIXED_LANGUAGES",
"Names must contain only one language",
),
0x4A => ("CHAR_NAME_PROFANE", "That name contains profanity"),
0x4B => ("CHAR_NAME_RESERVED", "That name is unavailable"),
0x4C => (
"CHAR_NAME_INVALID_APOSTROPHE",
"You cannot use an apostrophe as the first or last character of your name",
),
0x4D => (
"CHAR_NAME_MULTIPLE_APOSTROPHES",
"You can only have one apostrophe",
),
0x4E => (
"CHAR_NAME_THREE_CONSECUTIVE",
"You cannot use the same letter three times consecutively",
),
0x4F => (
"CHAR_NAME_INVALID_SPACE",
"You cannot use a space as the first or last character of your name",
),
_ => ("CHAR_CREATE_INVALID_NAME", "Invalid character name"),
};
strings.text(key, fallback)
}
#[cfg(test)]
mod tests {
/// **Every char-create result resolves to the sentence 1.12 actually ships**, read off the
/// player's own chain — `GlueStrings.lua` with `GlueLocalization.lua`'s `Localize()` patch over
/// it, assembled by the loader's own helper so this cannot assert a sentence the running client
/// would not show (2052). The regression for three bugs that literals had hidden (decision
/// 2045). Skips without client data.
#[test]
fn every_char_create_result_resolves_in_the_real_glue_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let strings = crate::glue_strings::table_from_chain(&mut chain);
// `0x36` used to stop at "…temporarily disabled."; the shipped string carries a second
// sentence, and a re-typed string is one nobody diffed.
assert_eq!(
char_result_text(&strings, 0x36),
"This server is currently queued and new character creation is temporarily disabled. \
Please try again during off peak hours."
);
// `0x50` is INVENTED text: 1.12 has no `CHAR_NAME_CONSECUTIVE_SPACES`, so the code takes
// the default arm like any other the table does not name. If a chain ever *does* carry
// such a key this assert is what will say so.
assert_eq!(
char_result_text(&strings, 0x50),
char_result_text(&strings, 0xFE),
"0x50 has no 1.12 string and must fall to the default arm, not invent one"
);
assert_eq!(char_result_text(&strings, 0xFE), "Invalid character name");
// Every named code must resolve to a REAL key — the fallback caption never showing is the
// whole point, since a fallback that matches hides a missing key forever.
for code in [
0x2Eu8, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x45, 0x46, 0x47, 0x48,
0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F,
] {
let shown = char_result_text(&strings, code);
assert!(!shown.is_empty(), "code {code:#04x} shows nothing");
}
}
/// **`0x4B` is the reserved-name code, and text alone can never prove it.**
///
/// It used to resolve `CHAR_CREATE_NAME_IN_USE`, whose enUS value is *identical* to
/// `CHAR_NAME_RESERVED`'s — "That name is unavailable" — so no comparison of the displayed
/// string could distinguish right from wrong. This asserts the key by *name* against the
/// shipped file, which is the only thing that can. A locale that words the two apart is
/// exactly the case this protects.
#[test]
fn the_reserved_name_code_names_the_reserved_key_not_the_in_use_one() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let map = crate::glue_strings::table_from_chain(&mut chain).into_map();
// Both keys exist and agree in enUS — which is why the mix-up was invisible.
let reserved = map.get("CHAR_NAME_RESERVED").expect("CHAR_NAME_RESERVED");
let in_use = map
.get("CHAR_CREATE_NAME_IN_USE")
.expect("CHAR_CREATE_NAME_IN_USE");
assert_eq!(
reserved, in_use,
"if these ever differ, the mix-up becomes visible"
);
// Prove we read the RESERVED one: a table with only that key altered must move 0x4B.
let mut probe = map.clone();
probe.insert("CHAR_NAME_RESERVED".into(), "RESERVED-SENTINEL".into());
let strings = GlueStrings::from_map(probe);
assert_eq!(char_result_text(&strings, 0x4B), "RESERVED-SENTINEL");
// …and 0x31, the genuine in-use code, must NOT have moved.
assert_eq!(char_result_text(&strings, 0x31), in_use.as_str());
}
use super::*;
/// The race grid's column order, pinned against the reference screen (director's screenshot,
@@ -228,6 +228,9 @@ fn scrollbar(
step: track_h * s / 2.0,
},
Button,
// At the end of its travel this arrow is disabled — `scroll_visuals` writes it
// and `crate::glue::glue_hilights` reads it, so the sheen dies with the arrow.
crate::glue::widgets::GlueDisabled(false),
ImageNode {
image: art.up.clone(),
rect: Some(tc_rect(art.size, SCROLL_BTN_TC)),
+18 -21
View File
@@ -19,7 +19,7 @@ use crate::glue::art::{
class_tc, race_tc, tc_rect, GlueArt, ALLIANCE_FILL, BACKDROP_ALLIANCE, BACKDROP_HORDE, BTN_BG,
BTN_HOVER, FALLBACK_ALPHA, HORDE_FILL,
};
use crate::glue::widgets::{FallbackFace, GlueDisabled, Hilight, HoverLabel};
use crate::glue::widgets::{FallbackFace, GlueDisabled, Hilight, HoverLabel, LockHighlight};
/// Refill everything that follows the selection — icon rects, dial labels, info texts, faction
/// tints, class-slot mapping — on selection change or a fresh spawn.
@@ -238,10 +238,10 @@ pub(super) fn refresh_hover(
&Children,
&mut BackgroundColor,
Has<FallbackFace>,
&mut LockHighlight,
),
(With<Button>, Without<crate::glue::widgets::GlueBtn>),
>,
mut hilights: Query<&mut Visibility, (With<Hilight>, Without<HoverLabel>)>,
mut labels: Query<&mut Visibility, (With<HoverLabel>, Without<Hilight>)>,
mut disables: Query<(&CreateAction, &mut GlueDisabled)>,
) {
@@ -254,23 +254,20 @@ pub(super) fn refresh_hover(
_ => false,
};
for (action, interaction, children, mut bg, fallback) in &mut buttons {
for (action, interaction, children, mut bg, fallback, mut locked) in &mut buttons {
let is_sel = selected(action);
let hovered = *interaction != Interaction::None;
let lit = is_sel || hovered;
// The sheen itself is `crate::glue::glue_hilights`' — this says only which row is chosen.
if locked.0 != is_sel {
locked.0 = is_sel;
}
// No-art fallback only: buttons spawned with a plain face get a hover shade. (Every node
// *has* a `BackgroundColor` — only a `FallbackFace`'s belongs to us.)
if fallback {
bg.0 = if lit { BTN_HOVER } else { BTN_BG };
}
for child in children {
if let Ok(mut vis) = hilights.get_mut(*child) {
*vis = if lit {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
if let Ok(mut vis) = labels.get_mut(*child) {
// Without icon art the label IS the button face — always visible.
let show = lit || art.races.is_none();
@@ -358,15 +355,19 @@ pub(super) fn scroll_drive(
pub(super) fn scroll_visuals(
art: Res<GlueArt>,
scrolls: Query<(&ComputedNode, &ScrollPosition), With<InfoScroll>>,
mut arrows: Query<(&ScrollArrow, &Interaction, &mut ImageNode, &Children)>,
mut arrows: Query<(
&ScrollArrow,
&Interaction,
&mut ImageNode,
&mut GlueDisabled,
)>,
mut thumbs: Query<(&ScrollThumb, &mut Node)>,
mut hides: Query<(&ScrollHides, &mut Visibility), Without<Hilight>>,
mut hilights: Query<&mut Visibility, With<Hilight>>,
) {
let Some(sc) = &art.scroll else {
return;
};
for (arrow, interaction, mut img, children) in &mut arrows {
for (arrow, interaction, mut img, mut off) in &mut arrows {
let Ok((node, pos)) = scrolls.get(arrow.scroll) else {
continue;
};
@@ -386,14 +387,10 @@ pub(super) fn scroll_visuals(
if img.image != *face {
img.image = face.clone();
}
for child in children {
if let Ok(mut vis) = hilights.get_mut(*child) {
*vis = if !disabled && *interaction != Interaction::None {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
// A scroll arrow at the end of its travel is genuinely disabled — said once, in the
// component the shared sheen pass already reads, rather than re-derived beside it.
if off.0 != disabled {
off.0 = disabled;
}
}
for (thumb, mut node) in &mut thumbs {
+36 -26
View File
@@ -120,38 +120,48 @@ fn spawn_screen(
let empty = GlueStrings::default();
let strings = strings.unwrap_or(&empty);
let mut root = commands.spawn((
CharCreateUi { s },
DynTint::Backdrop,
GlobalZIndex(SCREEN_Z),
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(BACKDROP),
));
root.with_children(|ui| {
// The 3D scene, full-bleed and first (everything else draws over it) — the ref's screen IS
// a fullscreen ModelFFX: the per-race background with the character standing in it (the
// booth renders both into this window-sized target). The whole pane drags to rotate, the
// ref's full-frame mouse rotation; the page tint behind it is the no-art fallback.
let mut pane = ui.spawn((
CreateAction::Model,
Button,
let root = commands
.spawn((
CharCreateUi { s },
DynTint::Backdrop,
GlobalZIndex(SCREEN_Z),
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
));
if let Some(image) = model_image {
pane.insert(ImageNode::new(image));
}
BackgroundColor(BACKDROP),
))
.with_children(|ui| {
// The 3D scene, full-bleed and first (everything else draws over it) — the ref's
// screen IS a fullscreen ModelFFX: the per-race background with the character standing
// in it (the booth renders both into this window-sized target). The whole pane drags
// to rotate, the ref's full-frame mouse rotation; the page tint behind it is the
// no-art fallback. It keeps the WINDOW while the chrome below does not: a pillarbox's
// bars are the booth camera's own output clear inside this same target (1619 §3).
let mut pane = ui.spawn((
CreateAction::Model,
Button,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
));
if let Some(image) = model_image {
pane.insert(ImageNode::new(image));
}
})
.id();
// ...and every piece of chrome hangs off the CANVAS — the boxed scene's own rect (decision
// 2091). The race/class towers are this screen's edge-anchored chrome: against the window they
// stand over the bars, and 1587's "no void at 21:9" held only because they did (1619 §2).
let mut canvas = commands.spawn((crate::glue::glue_canvas(), ChildOf(root)));
canvas.with_children(|ui| {
left_tower(ui, art, &font, s, strings);
// The WoW logo (`CharacterCreateWoWLogo`, 256×128 at (3,−7)) — after the tower, like the
+9 -31
View File
@@ -246,9 +246,11 @@ impl AddonsPanel {
self.staged.push(rows.iter().map(|a| a.enabled).collect());
self.list = rows;
} else {
// Same folder, same deterministic (alphabetical) discovery, read
// back-to-back: every call answers the same rows in the same order, only the
// `enabled` bits differ. That is what lets ONE metadata list carry N columns.
// Same folder, same deterministic discovery order, read back-to-back:
// every call answers the same rows in the same order, only the `enabled` bits
// differ. That is what lets ONE metadata list carry N columns. The order is
// `addons::sort_by_directory_order` — NTFS's, the reference's own listing
// order — and the point here is only that it is a pure function of the names.
debug_assert_eq!(
rows.len(),
self.list.len(),
@@ -566,12 +568,6 @@ struct AddonsUi;
#[derive(Component)]
pub(super) struct ScrollBand;
/// A panel button whose child [`Hilight`] lights on hover (the checkboxes' `UI-CheckBox-Highlight`,
/// the close X, the dropdown arrow, the open list's `UI-QuestTitleHighlight` rows, the scroll
/// arrows) — driven per frame by [`drive_addons_panel`] with **no respawn**, which is the point.
#[derive(Component)]
pub(super) struct HoverLit;
/// Spawn/despawn the panel, run its flows, and repaint when anything it shows has changed.
///
/// Ordered before the select screen's own click handling so a click that lands on the panel is
@@ -590,8 +586,6 @@ pub(super) fn drive_addons_panel(
mut sounds: MessageWriter<GlueSound>,
clicks: Res<crate::glue::GlueClicks>,
hovers: Query<(Entity, &AddonsAction, Ref<Interaction>)>,
lit: Query<(&Interaction, &Children), With<HoverLit>>,
mut hilights: Query<&mut Visibility, With<Hilight>>,
band: Query<(&ComputedNode, &UiGlobalTransform), With<ScrollBand>>,
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
) {
@@ -754,21 +748,9 @@ pub(super) fn drive_addons_panel(
return;
}
// ── hover: highlights + the tooltip, with the tree left alone ─────────────────────────────
for (interaction, children) in &lit {
let want = if *interaction == Interaction::None {
Visibility::Hidden
} else {
Visibility::Inherited
};
for &child in children {
if let Ok(mut vis) = hilights.get_mut(child) {
if *vis != want {
*vis = want;
}
}
}
}
// ── hover: the tooltip, with the tree left alone ──────────────────────────────────────────
// (The sheens are `crate::glue::glue_hilights`' — this panel used to drive its own, which is
// one of the four copies that let the realm list ship with none.)
let hover = hovers.iter().find_map(|(_, a, i)| {
if !matches!(*i, Interaction::Hovered | Interaction::Pressed) {
@@ -837,7 +819,7 @@ fn checkbox_button<A: Component>(
state: BoxState,
node: Node,
) {
let mut b = parent.spawn((action, Button, HoverLit, node));
let mut b = parent.spawn((action, Button, node));
match &art.checkbox {
Some(c) => {
b.insert((
@@ -1013,7 +995,6 @@ fn spawn_panel(
let mut x = b.spawn((
AddonsAction::Cancel,
Button,
HoverLit,
abs(BG_W - 42.0 - 32.0, 3.0, 32.0, 32.0),
));
if let Some(cb) = &art.close_btn {
@@ -1136,7 +1117,6 @@ fn spawn_panel(
let mut arrow = d.spawn((
AddonsAction::DropdownToggle,
Button,
HoverLit,
abs(DROP_W - 16.0 - 24.0, 18.0, 24.0, 24.0),
));
match (&art.dropdown_arrow_up, &art.dropdown_arrow_down) {
@@ -1351,7 +1331,6 @@ fn spawn_panel(
b.spawn((
action,
Button,
HoverLit,
ImageNode {
image: up.up.clone(),
rect: Some(tc_rect(up.size, crate::glue::art::SCROLL_BTN_TC)),
@@ -1557,7 +1536,6 @@ fn spawn_panel(
let mut row = list.spawn((
AddonsAction::DropdownPick(view),
Button,
HoverLit,
Node {
height: px(ROW_H),
min_width: px(120.0),
+26 -8
View File
@@ -30,17 +30,23 @@ pub(super) fn select_input(
clicks: Res<crate::glue::GlueClicks>,
keys: Res<ButtonInput<KeyCode>>,
mut roster: ResMut<Roster>,
mut realms: ResMut<crate::realm_select::Realms>,
pick: Res<CharPick>,
mut dialog: ResMut<DeleteDialog>,
mut panel: ResMut<super::addons::AddonsPanel>,
choice: Res<crate::net::RealmChoice>,
mut next: ResMut<NextState<ClientState>>,
mut sounds: MessageWriter<GlueSound>,
mut intent: ResMut<crate::login::LoginIntent>,
glue_dialog: Res<crate::glue::dialog::GlueDialog>,
time: Res<Time>,
mut last_click: Local<Option<(usize, f32)>>,
) {
// A modal owns the input while it is up — the delete confirm, or the AddOns list.
if dialog.open || panel.open {
// A modal owns the input while it is up — the delete confirm, the AddOns list, the realm
// list (which stands over this screen rather than replacing it), or the shared glue dialog
// (a refused character login is said in that one, and its Okay must not double as this
// screen's Enter World / Escape).
if dialog.open || panel.open || realms.shown || glue_dialog.is_open() {
return;
}
let now = time.elapsed_secs();
@@ -58,13 +64,15 @@ pub(super) fn select_input(
// within the window is the double-click → enter world.
let double =
last_click.is_some_and(|(row, at)| row == i && now - at < DOUBLE_CLICK_SECS);
let was = roster.selected();
*last_click = Some((i, now));
// The ref selects on **every** click, the row you were already on included — which
// is what re-squares the facing (`Roster::select_seq`). Only the double still
// needs the "already selected" test.
roster.select(Some(i));
if was == Some(i) && double {
// Selecting is gated on the row actually CHANGING — the ref's own
// `CharacterSelectButton_OnClick` is that gate and nothing else, so the row you
// are already on is not re-selected and keeps the facing you dragged into it
// (`Roster::click_row`, decision 2194).
roster.click_row(i);
// `OnDoubleClick` runs the same gated select and then enters the world
// unconditionally — it does not re-test what was selected before.
if double {
enter_world = true;
}
}
@@ -98,6 +106,16 @@ pub(super) fn select_input(
panel.open_for(realm, chars);
}
SelectAction::Back => back_to_login = true,
// The reference's `CHANGE_REALM`: raise the realm list **over** this screen. Nothing
// is torn down and nothing is disconnected — the character park serves the list in
// place and only leaves when a realm is picked, so Cancel puts the player back here
// with the session they never left. The pending pick still has to go, or the fresh
// roster on the OTHER realm would be auto-answered with a guid from this one.
SelectAction::ChangeRealm => {
sounds.write(GlueSound("gsLoginChangeRealmOK"));
roster.pending_pick = None;
crate::realm_select::open_over_char_select(&mut realms, &choice);
}
_ => {}
}
}
+268 -12
View File
@@ -32,18 +32,25 @@ mod screen;
use benilla_protocol::{CharAction, Character};
use bevy::prelude::*;
use crate::glue_strings::GlueStrings;
use crate::net::{
CharActionResultMessage, CharListMessage, CharPick, CharRequest, EnteredWorldMessage,
LoggedOutMessage,
CharActionResultMessage, CharListMessage, CharPick, CharRequest, CharacterLoginFailedMessage,
EnteredWorldMessage, LoggedOutMessage,
};
/// The app's lifecycle: which screen owns the session (decision 0193). Grows glue variants
/// (`RealmList`, …) as the glue arc fills in.
/// The app's lifecycle: which screen owns the session (decision 0193).
#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub(crate) enum ClientState {
/// Parked pre-logon at the login screen (decision 0539): the IO thread waits for credentials;
/// [`crate::login`]'s policy decides what answers it (the env fast path, the reconnect
/// resubmit, or the director's typed submit).
///
/// **The realm list is not one of these.** 0193 planned a `RealmList` variant and 2056 built
/// it; the reference has no such screen (`GlueParent.lua`'s `GlueScreenInfo` lists every glue
/// screen and the realm list is not among them — it is a `frameStrata="DIALOG"` frame shown
/// over whichever screen is up). It is [`crate::realm_select::Realms::shown`] now, and the
/// variant is gone rather than left unconstructed.
#[default]
Login,
/// Parked at character select: the select screen is up, the IO thread waits for a pick, and
@@ -112,6 +119,7 @@ impl Plugin for CharSelectPlugin {
apply_roster_policy,
enter_on_connected,
back_on_logout,
back_on_login_refused,
back_on_disconnect,
// LAST, and outside both `run_if`s: it mirrors the pick this frame ended
// with, and world entry is reached from the create screen too (1622).
@@ -124,6 +132,12 @@ impl Plugin for CharSelectPlugin {
input::rotate_model,
debug_select_dialog,
dialog::drive_delete_dialog,
// The reference's one `GlueDialog` (`crate::glue::dialog`), which on this
// screen carries the refused character login. Both glue screens run the
// same system over the same resource; what a press *means* is answered
// per-screen, and an `Error` — the only kind reachable here — needs no
// answer at all.
crate::glue::dialog::drive_glue_dialog,
// Before the list refresh, and before `select_input` reads a click that
// landed on the panel rather than the screen (decision 1196).
debug_select_addons,
@@ -132,13 +146,11 @@ impl Plugin for CharSelectPlugin {
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
refresh::feed_glue_preview,
crate::glue::art_swaps,
crate::glue::glue_button_visuals,
delete_result,
debug_select_shot,
crate::glue::sync_outlines,
)
.chain()
.before(crate::glue::GlueVisuals)
.run_if(in_state(ClientState::CharSelect)),
)
.chain()
@@ -171,10 +183,16 @@ pub(crate) struct Roster {
///
/// The ref's `SelectCharacter` zeroes the select facing **unconditionally**: `0x472950`'s
/// `mov ds:0xb4217c, 0` sits one instruction *above* the already-built discriminator, so it
/// dominates both legs, and the merged tail re-applies it geometrically — so re-clicking the
/// row you are already on snaps the character square again (wow-re
/// dominates both legs, and the merged tail re-applies it geometrically (wow-re
/// `glue/scratch/glue-preview-facing-law.md`, 1533). A counter rather than change-detection on
/// `selected`, because "the same index again" is a selection.
/// `selected`, because the engine re-squares on a *re*-selection of the same index too — which
/// is exactly what a roster refresh does, calling it with the index it already holds.
///
/// **The click is the one caller that does not reach it**, and the gate is in the stock Lua
/// rather than in the engine — see [`Roster::click_row`] and decision 2194. The caller census
/// is wow-re `glue/scratch/select-character-caller-gate.md`: of the ten Lua call sites only the
/// two click handlers are gated, and of `0x472740`'s four C callers two are the roster teardown
/// passing `-1` (so `0x472950` exits above the reset) — every ungated path re-squares.
pub(super) select_seq: u64,
/// The guid we answered the IO thread with; `Some` = a login is requested/live.
pub(super) pending_pick: Option<u64>,
@@ -249,6 +267,24 @@ impl Roster {
self.select_seq = self.select_seq.wrapping_add(1);
}
/// A click on a character row — the ref's `CharacterSelectButton_OnClick`, whose entire body is
/// the gate `if ( id ~= CharacterSelect.selectedIndex ) then CharacterSelect_SelectCharacter(id)`
/// (shipped `CharacterSelect.lua` l.305-310; `OnDoubleClick`, l.312-318, repeats it verbatim
/// before entering the world, and the ten `CharSelectCharacterButtonTemplate` buttons carry
/// `id="1".."10"` 1:1 with the character index, so `id` *is* the row).
///
/// So the row you are already on is never re-selected from a click, and the facing zero
/// [`Self::select`] owes never fires for it: the angle you dragged the character to survives
/// clicking it again. Decision 2194, correcting 1533 — the engine function is unconditional as
/// recorded (verified again, three ways, in wow-re
/// `glue/scratch/select-character-caller-gate.md`), but the click never reaches it: the binding
/// has exactly one live call site, and the two handlers that lead to it both gate.
pub(super) fn click_row(&mut self, row: usize) {
if self.selected != Some(row) {
self.select(Some(row));
}
}
/// The selected row, if any.
pub(super) fn selected(&self) -> Option<usize> {
self.selected
@@ -266,6 +302,15 @@ impl Roster {
self.pending_row().map(|c| c.map)
}
/// The pending pick's level — the loading screen's tip-of-the-day guard (decision 2077). The
/// reference keeps a synthesised flag at `[selChar+0x10a]` that `0x5b42a0` sets iff this byte
/// arrived as `0` in `SMSG_CHAR_ENUM`, and a set flag suppresses the tip. vmangos always sends
/// a real level, so the arm is unreachable against our server; it is honoured because it costs
/// one comparison and a different server is free to send a zero.
pub(crate) fn pending_level(&self) -> Option<u8> {
self.pending_row().map(|c| c.level)
}
/// The picked character's `(map, wow xyz)` — **where the world we are about to load actually
/// is**, known from the roster row a whole server round-trip before `SMSG_LOGIN_VERIFY_WORLD`
/// says so. The streamers aim at this during world entry (decision 0777); without it the only
@@ -570,6 +615,83 @@ fn back_on_disconnect(
next.set(ClientState::Login);
}
/// The server **refused** the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) → back to the
/// glue layer, pick cleared, and the refusal said out loud.
///
/// The entry it undoes was optimistic: the IO thread announces the connection in the same breath
/// as the pick (so the destination's tiles start streaming a round-trip early, decision 0777), and
/// the refusal arrives after. So this runs the logout's transition on a world that was only ever
/// half-built — the cover comes down with it ([`crate::loading_screen`]), and the IO thread's
/// relist puts the roster back underneath.
///
/// **Clearing the pick is the load-bearing half.** `pending_pick` is 0065's reconnect memory, and
/// the relist behind this refusal produces exactly the roster it auto-answers: left set, the
/// client would re-enter the character the server just refused, be refused again, and loop — from
/// behind a black screen, since each pass raises the cover afresh.
fn back_on_login_refused(
mut msgs: MessageReader<CharacterLoginFailedMessage>,
mut roster: ResMut<Roster>,
mut next: ResMut<NextState<ClientState>>,
mut dialog: ResMut<crate::glue::dialog::GlueDialog>,
strings: Option<Res<GlueStrings>>,
) {
let Some(msg) = msgs.read().last().copied() else {
return;
};
roster.pending_pick = None;
next.set(ClientState::CharSelect);
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
dialog.open_error(char_login_refusal_text(strings, msg.result));
}
/// The refusal byte, in the client's own words — the reference's jump table `0x5aae08`,
/// transcribed.
///
/// **The byte is a 1-based reason index, not a status code**, and that is the whole reason this
/// table exists rather than an offset. `ClientServices::OnCharLoginResult 0x5aad70` reads it as
/// `movzx eax,byte[ebp+8]; dec eax; cmp eax,5; ja <default>` and jumps through a six-entry table,
/// which maps `1..=6` onto the `CHAR_LOGIN_*` status codes `0x3e, 0x3f, 0x40, 0x42, 0x43, 0x44` —
/// **skipping `0x41`**, `CHAR_LOGIN_FAILED`, which is reachable only as the default. So the
/// tempting `0x3d + byte` is right for four rows and wrong for the rest, and `0` and anything past
/// `6` are "Login failed" rather than an out-of-bounds read: the `ja` guard precedes the table.
/// (VERIFIED off `WoW.exe`, cross-checked — wow-5875-re
/// `system/net/scratch/char-login-failed-law.md`. The strings are the shipped
/// `GlueStrings.lua:158-166`, quoted here only as the graceful-absence fallback.)
///
/// Both emulators speak this dialect. vmangos sends a bare `1` for all three of its refusal
/// guards (`PlayerLoading() || GetPlayer() || !guid.IsPlayer()`), so **every** refusal from our
/// own local server reads "World server is down" — the reference would say exactly that too, and
/// saying anything better here would be benilla inventing a diagnosis the client cannot make.
/// mangos-classic's live enum is `CharLoginFailReasons` `0x01..=0x08`; its `ResponseCodes`
/// `CHAR_LOGIN_*` block, whose values would NOT survive this table, is commented out.
pub(crate) fn char_login_refusal_text(strings: &GlueStrings, result: u8) -> &str {
let (key, fallback): (&str, &str) = match result {
1 => ("CHAR_LOGIN_NO_WORLD", "World server is down"),
2 => (
"CHAR_LOGIN_DUPLICATE_CHARACTER",
"A character with that name already exists",
),
3 => (
"CHAR_LOGIN_NO_INSTANCES",
"No instance servers are available",
),
4 => (
"CHAR_LOGIN_DISABLED",
"Login for that race, class, or character is currently disabled.",
),
5 => ("CHAR_LOGIN_NO_CHARACTER", "Character not found"),
6 => (
"CHAR_LOGIN_LOCKED_FOR_TRANSFER",
"Your character is currently locked as part of the paid character transfer process.",
),
// `0`, and `7` up (mangos-classic's `LOCKED_BY_BILLING` and `FAILED` among them): the
// switch's default arm, which is the only way `CHAR_LOGIN_FAILED` is ever reached.
_ => ("CHAR_LOGIN_FAILED", "Login failed"),
};
strings.text(key, fallback)
}
/// A confirmed `/logout` → back to the glue layer, pick cleared (the follow-up roster must be
/// shown, not auto-answered).
///
@@ -1068,6 +1190,7 @@ mod tests {
// One frame carrying both halves of the race, in the order the drain produces them.
app.world_mut().write_message(EnteredWorldMessage {
billing_time_rested: 0,
tutorial_flags: None,
});
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
@@ -1109,6 +1232,7 @@ mod tests {
app.world_mut().write_message(EnteredWorldMessage {
billing_time_rested: 0,
tutorial_flags: None,
});
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
@@ -1125,6 +1249,111 @@ mod tests {
);
}
/// **A refused character login takes the entry back** — and does it in the same frame the
/// entry was announced in.
///
/// The IO thread sends `CMSG_PLAYER_LOGIN` and emits `Connected` in the same breath (the
/// entry's head start, decision 0777), so when the server refuses immediately — which is the
/// vmangos guard's whole shape: `PlayerLoading() || GetPlayer() || !guid.IsPlayer()` answers
/// before it touches the database — `Connected` and `SMSG_CHARACTER_LOGIN_FAILED` reach the
/// app in ONE drain. Both edges then fire in one `Update`, and the refusal has to be the last
/// word, exactly as a lost session does one test up.
///
/// Without this the client flips `InWorld` against a world the server has just declined to
/// give it: no snap, no objects, and a loading cover armed for a snap that will never come —
/// which is the shape the whole fix exists to end.
#[test]
fn a_refused_login_beats_the_entry_it_revokes() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.init_resource::<crate::ui_script::PlayerUiHover>()
.init_resource::<crate::ui_script::UiKeyboardCapture>()
.add_message::<EnteredWorldMessage>()
.add_message::<CharacterLoginFailedMessage>()
.init_resource::<crate::glue::dialog::GlueDialog>()
.add_systems(Update, (enter_on_connected, back_on_login_refused).chain());
app.world_mut().resource_mut::<Roster>().pending_pick = Some(7);
app.world_mut().write_message(EnteredWorldMessage {
billing_time_rested: 0,
tutorial_flags: None,
});
app.world_mut()
.write_message(CharacterLoginFailedMessage { result: 0x01 });
app.update();
app.update(); // `StateTransition` applies the pending state at the next frame
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect,
"the refusal must win — the reference leaves the player on the select screen it \
never actually took them off",
);
assert_eq!(
app.world().resource::<Roster>().pending_pick,
None,
"and the pick goes with it: the relist behind the refusal is auto-answered with \
`pending_pick`, so keeping it would re-enter the character just refused, forever",
);
// And the player is told. No GlueStrings in this App, so this is the fallback literal —
// the shipped sentence for vmangos's `1` is asserted against the real chain below.
assert_eq!(
app.world()
.resource::<crate::glue::dialog::GlueDialog>()
.text,
"World server is down",
"a refusal the player cannot see is the bug this whole path exists to end",
);
}
/// **Every refusal byte resolves to the sentence 1.12 actually ships**, read off the player's
/// own chain — the char-create screen's regression (2045/2052) applied to the table this
/// screen owns. Skips without client data.
///
/// The two rows worth writing down. `1` is the only byte vmangos ever sends, and it reads
/// "World server is down" — not the "duplicate character" its own guard actually means, which
/// is the server's dialect and not ours to improve on. And `4` is where the tempting
/// `0x3d + byte` arithmetic breaks: the reference's table skips `CHAR_LOGIN_FAILED` (0x41),
/// so `4` is `CHAR_LOGIN_DISABLED` and not the "Login failed" an offset would give.
#[test]
fn every_refusal_byte_resolves_in_the_real_glue_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let strings = crate::glue_strings::table_from_chain(&mut chain);
assert_eq!(char_login_refusal_text(&strings, 1), "World server is down");
assert_eq!(
char_login_refusal_text(&strings, 4),
"Login for that race, class, or character is currently disabled.",
);
assert_eq!(char_login_refusal_text(&strings, 5), "Character not found");
// The default arm, from both ends of the guard the reference's `dec eax; cmp eax,5; ja`
// draws — and it must be the same sentence, since there is only one default.
let default = char_login_refusal_text(&strings, 0);
assert_eq!(default, "Login failed");
for byte in [7u8, 8, 0x3f, 0x43, 0xff] {
assert_eq!(char_login_refusal_text(&strings, byte), default);
}
// Every named byte must resolve to a REAL key — a fallback that happens to match would
// hide a missing key forever.
let map = crate::glue_strings::table_from_chain(&mut chain).into_map();
for key in [
"CHAR_LOGIN_NO_WORLD",
"CHAR_LOGIN_DUPLICATE_CHARACTER",
"CHAR_LOGIN_NO_INSTANCES",
"CHAR_LOGIN_DISABLED",
"CHAR_LOGIN_NO_CHARACTER",
"CHAR_LOGIN_LOCKED_FOR_TRANSFER",
"CHAR_LOGIN_FAILED",
] {
assert!(map.contains_key(key), "{key} is not in the shipped table");
}
}
/// B119 — a created character is selected against the roster **already in hand**. `net::io`
/// re-enumerates and emits the fresh roster BEFORE the create result, so by the time
/// `note_created` runs the row exists and the roster message is spent; a version that only armed
@@ -1262,6 +1491,33 @@ mod tests {
);
}
/// **The report**: drag the character round on the select screen, then click the row it is
/// already standing on, and it snapped square again — the reference keeps the angle.
/// `CharacterSelectButton_OnClick`'s whole body is `if ( id ~= CharacterSelect.selectedIndex )`,
/// so that click never reaches the engine's unconditional facing zero at all (2194, correcting
/// 1533 — which verified the engine function and never asked what calls it). The facing reset
/// rides `select_seq`, so "did it re-square" is exactly "did the counter move".
#[test]
fn re_clicking_the_selected_row_keeps_the_facing() {
let mut roster = Roster {
chars: vec![character(1, "Kerwind"), character(2, "Xero")],
..Roster::default()
};
roster.click_row(0);
let squared = roster.select_seq;
roster.click_row(0);
assert_eq!(
roster.select_seq, squared,
"a click on the row already selected must not re-select — the dragged angle survives",
);
roster.click_row(1);
assert_eq!(
roster.select_seq,
squared + 1,
"…while a click on a DIFFERENT row selects, and squares the character it brings up",
);
}
/// …but a just-created character still wins, which is the reference's precedence and the
/// order it comes in: the C side pushes the restored index into Lua first, and
/// `UpdateCharacterList`'s deferred `selectLast` flag overwrites it (B119 stays fixed).
@@ -1325,7 +1581,7 @@ mod tests {
#[test]
fn only_entering_the_world_writes_the_cvar() {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut script = benilla_ui::script::UiScript::new().unwrap();
let script = benilla_ui::script::UiScript::new().unwrap();
script.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
let mut app = App::new();
@@ -1373,7 +1629,7 @@ mod tests {
fn a_replaced_vm_is_told_the_remembered_row_again() {
let (tx, _rx) = crossbeam_channel::unbounded();
let fresh = || {
let mut s = benilla_ui::script::UiScript::new().unwrap();
let s = benilla_ui::script::UiScript::new().unwrap();
s.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
s
};
+80 -37
View File
@@ -7,7 +7,7 @@
use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::glue::widgets::{GlueDisabled, Hilight};
use crate::glue::widgets::{GlueDisabled, Hilight, LockHighlight};
use crate::glue_strings::GlueStrings;
use crate::net::NetStatus;
use crate::portrait::{GlueLook, GluePreview, SelectLook};
@@ -92,30 +92,30 @@ pub(super) fn refresh_list(
t.0 = new;
}
}
// The realm banner: "<name> (PVP)" per the realm type; "(Server down)" while unreachable
// (the ref's SERVER_DOWN suffix); a plain connecting note before the first roster.
if let Ok(mut t) = banner.single_mut() {
let new = match &roster.realm {
Some(realm) => {
let suffix = match realm.realm_type {
1 => strings.text("PVP_PARENTHESES", "(PVP)"),
6 => strings.text("RP_PARENTHESES", "(RP)"),
8 => strings.text("RPPVP_PARENTHESES", "(RPPVP)"),
_ => "",
// `GetServerName`'s own `isPVP, isRP` pair, from the one table that owns it.
// The banner's normal-realm arm is EMPTY, unlike the realm list's `Normal` — the
// reference leaves `serverType = ""` when neither flag is set.
let suffix = match crate::realm_select::pvp_rp(realm.realm_type) {
(true, true) => strings.text("RPPVP_PARENTHESES", "(RPPVP)"),
(false, true) => strings.text("RP_PARENTHESES", "(RP)"),
(true, false) => strings.text("PVP_PARENTHESES", "(PVP)"),
(false, false) => "",
};
let down = if status.last_reason.is_some() && roster.pending_pick.is_none() {
format!("\n({})", strings.text("SERVER_DOWN", "Server down"))
} else {
String::new()
};
format!("{} {}{down}", realm.name, suffix)
.trim_end()
.to_string()
let down = (status.last_reason.is_some() && roster.pending_pick.is_none())
.then(|| strings.text("SERVER_DOWN", "Server down"));
realm_banner(&realm.name, suffix, down)
}
None => match &status.last_reason {
Some(_) => strings.text("SERVER_DOWN", "Server down").to_string(),
None => "Connecting…".to_string(),
},
// **`CharSelectRealmName:Hide()`** (`CharacterSelect.lua` l.66): with no server name
// the reference hides the FontString outright — it has no string for this case, and
// ours used to invent one (`"Connecting…"`). Empty text is our Hide.
//
// It is also all but unreachable now. The realm is chosen *before* the world dial, so
// by the time this screen exists the session has one; what is left here is the
// no-network harness, which is exactly the case the reference draws nothing for.
None => String::new(),
};
if t.0 != new {
t.0 = new;
@@ -123,41 +123,59 @@ pub(super) fn refresh_list(
}
}
/// The realm banner, composed the way `CharacterSelect_OnShow` composes it (`CharacterSelect.lua`
/// l.48-62): the down note is appended to the **name**, and the realm-type suffix goes after that
/// whole thing.
///
/// ```text
/// serverName = serverName.."\n("..TEXT(SERVER_DOWN)..")"; -- only while disconnected
/// CharSelectRealmName:SetText(serverName.." "..serverType);
/// ```
///
/// A separate function because the order is exactly what drifted: ours put the suffix first and the
/// down note last, under a comment claiming the reference's shape. Nothing showed while the suffix
/// was `"(PVP)"` — parentheses read plausibly at either end. Decision 2052 made the suffix the enGB
/// patch's `"PVP"`, a bare word, and the order became legible. A comment could not fail; this can.
fn realm_banner(name: &str, suffix: &str, down: Option<&str>) -> String {
let name = match down {
Some(reason) => format!("{name}\n({reason})"),
None => name.to_string(),
};
// The reference appends `" "..serverType` unconditionally, leaving a trailing space on a realm
// with no type; we trim it, since ours is a laid-out node rather than a Lua string.
format!("{name} {suffix}").trim_end().to_string()
}
/// Per-frame interaction visuals + button states: the row highlight (hover ∪ selected — the ref's
/// `LockHighlight` on the selected row), and the enabled states — Enter World / Delete disable on
/// an empty list (the ref's `UpdateCharacterList`), Create hides at the 10-cap or disconnected,
/// Change Realm stays disabled (decision 0465 §6).
/// an empty list (the ref's `UpdateCharacterList`), Create hides at the 10-cap or disconnected.
/// (Change Realm was drawn deliberately dead under 0465 §6, until 2056 gave it something to do and
/// 2072 made it work.)
#[allow(clippy::type_complexity)]
pub(super) fn refresh_banner_and_buttons(
roster: Res<Roster>,
mut rows: Query<(&SelectAction, &Interaction, &Children), With<Button>>,
mut hilights: Query<&mut Visibility, With<Hilight>>,
mut rows: Query<(&SelectAction, &mut LockHighlight), With<Button>>,
mut disables: Query<(&SelectAction, &mut GlueDisabled)>,
mut create_vis: Query<
(&SelectAction, &mut Visibility),
(With<crate::glue::widgets::GlueBtn>, Without<Hilight>),
>,
) {
for (action, interaction, children) in &mut rows {
// `LockHighlight` on the chosen row and nothing about *visibility*: hovering is
// `crate::glue::glue_hilights`' question, and the two used to be one expression here.
for (action, mut locked) in &mut rows {
let SelectAction::Row(i) = action else {
continue;
};
let lit = roster.selected() == Some(*i) || *interaction != Interaction::None;
for child in children {
if let Ok(mut vis) = hilights.get_mut(*child) {
*vis = if lit {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
let want = roster.selected() == Some(*i);
if locked.0 != want {
locked.0 = want;
}
}
let have_chars = !roster.chars.is_empty();
for (action, mut disabled) in &mut disables {
let want = match action {
SelectAction::EnterWorld | SelectAction::Delete => !have_chars,
SelectAction::ChangeRealm => true,
_ => false,
};
if disabled.0 != want {
@@ -193,8 +211,10 @@ pub(super) fn feed_glue_preview(
// glue screens, so the reset lives at the selection edge rather than in the resource.
//
// Keyed on the selection **counter**, not on who is shown: the ref's `SelectCharacter` zeroes
// the facing unconditionally (`0x472950`, above the already-built discriminator), so clicking
// the row you are already on re-squares the character too. See `Roster::select_seq`.
// the facing unconditionally (`0x472950`, above the already-built discriminator), so selecting
// the same index again — what a roster refresh does — re-squares the character too. A *click*
// on the selected row is gated out one level up and never gets here (`Roster::click_row`,
// 2194). See `Roster::select_seq`.
if *showing != Some(roster.select_seq) {
*showing = Some(roster.select_seq);
preview.yaw = 0.0;
@@ -211,3 +231,26 @@ pub(super) fn feed_glue_preview(
preview.look = look;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The composition order, pinned against `CharacterSelect.lua` l.48-62.
#[test]
fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() {
assert_eq!(realm_banner("Kalimdor", "PVP", None), "Kalimdor PVP");
assert_eq!(
realm_banner("Kalimdor", "PVP", Some("Server down")),
"Kalimdor\n(Server down) PVP",
"the suffix follows the whole name+note, not the name alone"
);
// No realm type: the reference's unconditional " " is all that would be left, and a banner
// does not end in whitespace.
assert_eq!(realm_banner("Kalimdor", "", None), "Kalimdor");
assert_eq!(
realm_banner("Kalimdor", "", Some("Server down")),
"Kalimdor\n(Server down)"
);
}
}
+55 -35
View File
@@ -8,8 +8,8 @@
//! name (`GlueFontNormalHuge` at BOTTOM (0,100)), Enter World (200×60 at BOTTOM (0,30)) with the
//! rotate pair tucked under it, Back (BOTTOMRIGHT (−30,25)) and Delete Character to its left, and
//! the right-column character frame: 260×642 at TOPRIGHT (−5,−15), `Glue-Tooltip` backdrop tinted
//! `DEFAULT_TOOLTIP_COLOR` at 0.85 alpha, holding the realm banner, the disabled Change Realm
//! button (realm choice is out of scope — decision 0465 §6), ten 256×70 row buttons from TOPLEFT
//! `DEFAULT_TOOLTIP_COLOR` at 0.85 alpha, holding the realm banner, the Change Realm
//! button (back to [`crate::realm_select`]'s list), ten 256×70 row buttons from TOPLEFT
//! (24,−65) at the authored 57 px pitch (13 px overlap, hit-inset 15), and Create New Character at
//! the frame's BOTTOM (0,15). The delete dialog is [`super::dialog`]'s.
@@ -20,7 +20,7 @@ use bevy::window::PrimaryWindow;
use crate::glue::art::{GlueArt, BACKDROP, DIM, GOLD, NAME_EDGE};
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
use crate::glue::widgets::{
abs, glue_button, outlined_text, overlay, GlueBtnKind, GlueText, Hilight,
abs, glue_button, outlined_text, overlay, GlueBtnKind, GlueText, Hilight, LockHighlight,
};
use crate::glue_strings::GlueStrings;
use crate::portrait::{GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
@@ -41,7 +41,7 @@ pub(super) enum SelectAction {
Back,
Delete,
CreateChar,
/// Rendered disabled — realm choice is out of scope (decision 0465 §6).
/// Back to the realm list — drops the parked session, keeps the logon.
ChangeRealm,
/// Open the AddOns list (decision 1197) — the reference's `CharacterSelectAddonsButton`.
Addons,
@@ -167,40 +167,51 @@ fn spawn_screen(
let empty = GlueStrings::default();
let strings = strings.unwrap_or(&empty);
let mut root = commands.spawn((
CharSelectUi {
with_art: art.button_up.is_some(),
s,
},
GlobalZIndex(SCREEN_Z),
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(BACKDROP),
));
root.with_children(|ui| {
// The 3D scene, full-bleed and first (everything else draws over it) — the ref's screen IS
// the fullscreen ModelFFX: the selected race's scene with the geared character standing in
// it. The whole pane drags to rotate (the ref's full-frame mouse rotation); the page tint
// behind it is the no-art fallback.
let mut pane = ui.spawn((
SelectAction::Scene,
Button,
let root = commands
.spawn((
CharSelectUi {
with_art: art.button_up.is_some(),
s,
},
GlobalZIndex(SCREEN_Z),
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
));
if let Some(image) = model_image {
pane.insert(ImageNode::new(image));
}
BackgroundColor(BACKDROP),
))
.with_children(|ui| {
// The 3D scene, full-bleed and first (everything else draws over it) — the ref's
// screen IS the fullscreen ModelFFX: the selected race's scene with the geared
// character standing in it. The whole pane drags to rotate (the ref's full-frame mouse
// rotation); the page tint behind it is the no-art fallback. It keeps the WINDOW while
// the chrome below does not: a pillarbox's bars are the booth camera's own output
// clear inside this window-sized target (1619 §3), so the pane that samples it covers
// the window and brings the bars with it.
let mut pane = ui.spawn((
SelectAction::Scene,
Button,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
));
if let Some(image) = model_image {
pane.insert(ImageNode::new(image));
}
})
.id();
// ...and every piece of chrome hangs off the CANVAS — the boxed scene's own rect (decision
// 2091). Anchored to the window instead, the logo and the whole right-hand character frame
// (with Delete Character and Back) stood out in the bars at 21:9 (B377).
let mut canvas = commands.spawn((crate::glue::glue_canvas(), ChildOf(root)));
canvas.with_children(|ui| {
// The WoW logo (`CharacterSelectLogo`, 256×128 at TOPLEFT (3,−7)).
if let Some(logo) = &art.logo {
ui.spawn((ImageNode::new(logo.clone()), abs(s, 3.0, 7.0, 256.0, 128.0)));
@@ -388,8 +399,9 @@ fn character_frame(
font,
s,
);
// Change Realm (below the banner) — rendered disabled: realm choice is out of scope
// (decision 0465 §6; a dead-but-enabled button would lie).
// Change Realm (below the banner) — live. It was drawn permanently disabled for as
// long as there was no realm list behind it to go back to; there is now, and it
// costs a world dial rather than a re-login (`crate::realm_select`).
frame
.spawn((Node {
position_type: PositionType::Absolute,
@@ -458,7 +470,8 @@ fn row_button(
.spawn((
SelectAction::Row(row),
Button,
Visibility::Hidden, // shown by the refresh while the roster has this row
LockHighlight::default(), // `LockHighlight` on the selected row (refresh.rs)
Visibility::Hidden, // shown by the refresh while the roster has this row
Node {
position_type: PositionType::Absolute,
left: px(24.0),
@@ -655,6 +668,7 @@ pub(super) fn exit_select(
roots: Query<Entity, With<CharSelectUi>>,
mut preview: ResMut<GluePreview>,
mut dialog: ResMut<super::dialog::DeleteDialog>,
mut glue_dialog: ResMut<crate::glue::dialog::GlueDialog>,
) {
for e in &roots {
commands.entity(e).despawn();
@@ -664,4 +678,10 @@ pub(super) fn exit_select(
preview.look = None;
preview.scene = None;
dialog.close();
// The shared glue dialog is this screen's too while it is up (a refused character login), and
// it must not follow us into the world or onto the create screen — the login screen's own
// `exit_login` closes it on the same edge for the same reason. The tree goes with it **here**
// rather than being left to the driver: the driver runs on the glue screens only, so on the
// edges out of the glue layer there would be nobody left to despawn the root.
glue_dialog.dismiss(&mut commands);
}
+8 -1
View File
@@ -82,7 +82,7 @@ use crate::entities::StandBoxHeight;
use crate::net::{Embodied, Guid, NetEntity, SelfGuid};
use crate::ui_chat::{default_color, ChatEventKind};
use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas};
use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, TextSeat, UiFontAtlas};
use crate::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlates};
use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::view::WorldCamera;
@@ -745,6 +745,13 @@ fn draw_bubble(
},
z + Z_TEXT,
spec,
// The bubble frame seats on the DEVICE pixel grid ([`device_snap`], 1398) and slides with
// the speaker; its text must be rigid against it. Snapping the block top on the UI's
// logical grid instead made the text pop a whole px every second step the frame took —
// the V-plate's defect at the sibling site, exactly as 1398 found the snap itself missing
// here. Unlike the plate this rect is real (a margin box), so the degenerate-rect
// carve-out never covered it.
TextSeat::Exact,
);
drop(e);
if trace {
+4 -4
View File
@@ -35,9 +35,9 @@
//! rather than being skipped (`0x48efe0`).
//! - **ESC is not an engine binding.** `StopCinematic` has zero native callers in the reference:
//! the only skip path is `CinematicFrame.xml`'s own `OnKeyDown`, which is why benilla's copy of
//! that frame (`assets/ui/CinematicFrame.xml`) carries the same handler and why the Lua binding
//! queues [`SessionRequest::StopCinematic`](benilla_ui::script::SessionRequest) rather than
//! reaching in here.
//! that frame (stock `Interface\FrameXML\CinematicFrame.xml`) carries the same handler and why
//! the Lua binding queues [`SessionRequest::StopCinematic`](benilla_ui::script::SessionRequest)
//! rather than reaching in here.
//! - **The ack ends the run, once.** `CMSG_COMPLETE_CINEMATIC` goes out on a natural end and on an
//! ESC skip alike (`0x48f080`). Decision 0196 is why it can never be dropped: unacked, vmangos
//! re-anchors object visibility to its own copy of the flying camera and everything around the
@@ -238,7 +238,7 @@ impl Plugin for CinematicPlugin {
feed_ui
.in_set(WorldStage::Input)
.after(drive)
.run_if(not(crate::ui_script::ingame_ui_pending)),
.run_if(crate::ui_script::ingame_ui_up),
)
// A cinematic cannot outlive the world it was flying over: leaving drops it silently,
// with no ack, exactly as the reference's own leave-world teardown does (`0x490a80`
+121 -14
View File
@@ -112,12 +112,47 @@ pub(crate) fn miss_word(code: u8) -> Option<(&'static str, u8)> {
pub(crate) const COLOR_SPELL_GOLD: u32 = 0xFFFF_DE00;
pub(crate) const COLOR_PET_MELEE_ORANGE: u32 = 0xFFFF_8400;
/// The cvar gates at their shipped defaults (like the nameplate cvars, consts until a cvar
/// system exists): CombatDamage masters ALL floating damage text; the Pet* pair gates only the
/// owned-source sub-cases (the self sub-case is unconditional — the byte refinement).
const COMBAT_DAMAGE: bool = true;
const PET_MELEE_DAMAGE: bool = true;
const PET_SPELL_DAMAGE: bool = true;
/// **The three floating-combat-text CVar gates, live** — `CombatDamage` (`[0xc4d944]`),
/// `PetMeleeDamage` (`[0xc4d9cc]`) and `PetSpellDamage` (`[0xc4d99c]`), each read as the record's
/// int `+0x28`.
///
/// Each has a **closed reader census** in the reference: one store and exactly two reads
/// image-wide, one in the localized-WORD emitter `0x607140` and one in the `"%d"` NUMBER emitter
/// `0x6128b0` (wow-re `playername/scratch/worldtext-spawn-and-law.md` §4,
/// `combattext-color-law.md`). Both branch targets are function epilogues, so a gate that fails
/// **suppresses the emit entirely** — it never falls through to a default colour, which is why
/// [`damage_color`] returns `Option` rather than a colour.
///
/// `CombatDamage` is the master: off, nothing floats over any unit from any source, and — despite
/// the CVar's own help text saying "damage numbers" — that includes the miss/dodge/parry/block/
/// absorb/resist WORDS, because the word emitter carries the same gate. The `Pet*` pair are
/// downstream sub-gates that only ever see the owned-by-you branch; the self sub-case is
/// unconditional.
///
/// **`PetMeleeDamage` and `PetSpellDamage` do not split on "melee vs spell" the way a player
/// would mean it.** The selector is `B` — no spell record (a real melee swing) *or* the spell's
/// `AttributesEx` bit 15 — so a pet spell carrying that bit is gated by `PetMeleeDamage` and
/// coloured orange. Which spell ids carry it is on wow-re's own Open board.
///
/// These were `const bool`s with the comment "consts until a cvar system exists". The CVar system
/// existed; this is the row 2077's census was holding the place for.
#[derive(Resource, Clone, Copy)]
pub(crate) struct DamageTextGates {
pub(crate) combat_damage: bool,
pub(crate) pet_melee: bool,
pub(crate) pet_spell: bool,
}
impl Default for DamageTextGates {
/// The reference's registered defaults — all three `"1"`.
fn default() -> Self {
Self {
combat_damage: true,
pet_melee: true,
pet_spell: true,
}
}
}
/// The `0x5efea0` source-ownership classes that may draw (`K`): the active player itself, or a
/// unit it owns (pet/guardian/totem — Summoned/CreatedBy = me). Every other source class (other
@@ -128,20 +163,42 @@ pub(crate) enum DamageSource {
Pet,
}
/// The color law's `B` bit — "this damage is melee-STYLED" — from the spell record the emitting
/// call site pushes: `(recordPtr == 0) || sign(byte[SpellRec+0x25])`, i.e. no record at all, or
/// `AttributesEx3` bit 15 ([`benilla_formats::SpellDisplay::melee_white_damage`]). `display` is
/// the row the site resolved; `None` stands for BOTH "the site pushed NULL" (a melee swing, a
/// damage shield) and "we have no catalog" — the client degrades a NULL record to melee-styled,
/// so a missing catalog degrades the same way.
///
/// **It is the WORD emitter's bit as much as the number's** (decision 2229). `0x607140` and
/// `0x6128b0` are separate functions — different arg counts, different register allocation, not
/// the "byte-identical twins" an earlier reading called them — but they compute `B` and `K`
/// identically, and seven of the eight `0x607140` call sites push a resolved SpellRec. Only the
/// melee swing's word site (`0x624511`, whose seven predecessors are all `6a 00 push 0x0`) pushes
/// NULL. So a spell's "Miss"/"Resist" is spell-GOLD exactly like its number, and only a white
/// hit's miss is white.
pub(crate) fn melee_styled(display: Option<&benilla_formats::SpellDisplay>) -> bool {
display.is_none_or(benilla_formats::SpellDisplay::melee_white_damage)
}
/// The color branch (`0x6128b0` `6128f6`–`612964`): the effective override for a qualifying
/// source, by `B` (`melee` = record NULL; the AttributesEx bit-15 leg is a named divergence) and
/// `K`. `None` = the whole emit is gated off (CombatDamage master, or the pet path's Pet* cvar);
/// `Some(None)` = draw with the category row's default (white); `Some(Some(argb))` = draw with
/// the override. Crit never enters this pick.
pub(crate) fn damage_color(source: DamageSource, melee: bool) -> Option<Option<u32>> {
if !COMBAT_DAMAGE {
pub(crate) fn damage_color(
gates: DamageTextGates,
source: DamageSource,
melee: bool,
) -> Option<Option<u32>> {
if !gates.combat_damage {
return None;
}
match (source, melee) {
(DamageSource::Player, true) => Some(None),
(DamageSource::Player, false) => Some(Some(COLOR_SPELL_GOLD)),
(DamageSource::Pet, true) => PET_MELEE_DAMAGE.then_some(Some(COLOR_PET_MELEE_ORANGE)),
(DamageSource::Pet, false) => PET_SPELL_DAMAGE.then_some(Some(COLOR_SPELL_GOLD)),
(DamageSource::Pet, true) => gates.pet_melee.then_some(Some(COLOR_PET_MELEE_ORANGE)),
(DamageSource::Pet, false) => gates.pet_spell.then_some(Some(COLOR_SPELL_GOLD)),
}
}
@@ -467,21 +524,71 @@ mod tests {
/// (PetSpellDamage). Crit never enters the pick (it only selects the pop row).
#[test]
fn damage_color_matches_the_byte_table() {
assert_eq!(damage_color(DamageSource::Player, true), Some(None));
let on = DamageTextGates::default();
assert_eq!(damage_color(on, DamageSource::Player, true), Some(None));
assert_eq!(
damage_color(DamageSource::Player, false),
damage_color(on, DamageSource::Player, false),
Some(Some(COLOR_SPELL_GOLD))
);
assert_eq!(
damage_color(DamageSource::Pet, true),
damage_color(on, DamageSource::Pet, true),
Some(Some(COLOR_PET_MELEE_ORANGE))
);
assert_eq!(
damage_color(DamageSource::Pet, false),
damage_color(on, DamageSource::Pet, false),
Some(Some(COLOR_SPELL_GOLD))
);
// The byte values themselves (`0x5fa0b0`/`0x5fa0f0` hard-inits).
assert_eq!(COLOR_SPELL_GOLD, 0xFFFF_DE00);
assert_eq!(COLOR_PET_MELEE_ORANGE, 0xFFFF_8400);
}
/// **The three gates SUPPRESS; they never recolour** — both of the reference's read sites
/// branch to a function epilogue, so a failed gate means no emit at all. And the master's
/// reach is total: `CombatDamage = 0` takes the self sub-case with it, which is the one case
/// no `Pet*` row can reach.
#[test]
fn the_three_gates_suppress_the_emit_rather_than_recolour_it() {
let master_off = DamageTextGates {
combat_damage: false,
..Default::default()
};
for source in [DamageSource::Player, DamageSource::Pet] {
for melee in [true, false] {
assert_eq!(
damage_color(master_off, source, melee),
None,
"CombatDamage=0 must suppress {source:?} melee={melee}"
);
}
}
// A pet sub-gate takes ONLY its own branch — and leaves the player's alone, because the
// self sub-case is unconditional in the reference.
let no_pet_melee = DamageTextGates {
pet_melee: false,
..Default::default()
};
assert_eq!(damage_color(no_pet_melee, DamageSource::Pet, true), None);
assert_eq!(
damage_color(no_pet_melee, DamageSource::Pet, false),
Some(Some(COLOR_SPELL_GOLD)),
"PetSpellDamage still governs the pet's spell branch"
);
assert_eq!(
damage_color(no_pet_melee, DamageSource::Player, true),
Some(None),
"the self sub-case is unconditional"
);
let no_pet_spell = DamageTextGates {
pet_spell: false,
..Default::default()
};
assert_eq!(damage_color(no_pet_spell, DamageSource::Pet, false), None);
assert_eq!(
damage_color(no_pet_spell, DamageSource::Pet, true),
Some(Some(COLOR_PET_MELEE_ORANGE))
);
}
}

Some files were not shown because too many files have changed in this diff Show More