Merge upstream main (samwhosung/benilla fd386e75) into lighting

71 upstream commits: the repo-wide comment rewrite, the hover highlight as the
scene ambient, M2 Mod/Mod2x batches drawing the bare texel, test and contribution
changes. Conflicts were comment-vs-code in 36 files; where upstream changed only
comments our code was kept, and wow_model.wgsl carries both our lighting lanes
and upstream's new unlit/Mod/Mod2x paths.

Follow-ups folded in:
- our nine Advanced Graphics / water / fog options tests take upstream's
  wow_data_or_skip! guard
- PostUpdate ambiguity ceiling re-measured under upstream's new census: 466,
  with the per-family breakdown in the comment
- LIGHTING.md: new upstream base

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 00:18:30 +01:00
co-authored by Claude Opus 5.5
1454 changed files with 92823 additions and 127116 deletions
+5
View File
@@ -0,0 +1,5 @@
# `cargo play` — the run loop. Same optimization as `--release`, plus incremental compilation (see
# `[profile.play]` in the workspace Cargo.toml for why it is a separate profile and not a flip of
# `release`). Env vars still prefix it: `WOW_USER=one WOW_PASS=pone cargo play`.
[alias]
play = "run --profile play -p benilla"
+25
View File
@@ -0,0 +1,25 @@
name: Bug report
description: Something benilla gets wrong, or does differently from 1.12.1.
body:
- type: textarea
id: what
attributes:
label: What happened
description: Screenshots or a clip help a lot; drag them in here.
validations:
required: true
- type: textarea
id: expected
attributes:
label: What 1.12.1 does instead
- type: textarea
id: steps
attributes:
label: How to see it
placeholder: Where you were and what you did.
- type: input
id: build
attributes:
label: Build and setup
description: The `benilla build` line from the start of the terminal output, your OS, and the server you play on.
placeholder: "benilla build 64cca13 · 2026-09-24 · release, macOS 15, vmangos"
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Discord
url: https://discord.gg/wJSJx467G4
about: Questions, ideas and chat.
+7
View File
@@ -0,0 +1,7 @@
<!-- One change per pull request. docs/CONTRIBUTING.md says what gets in. -->
**What this changes**
**What 1.12.1 does, and how you know** (the client's behaviour, a DBC field, a FrameXML line, a capture)
**How you checked it** (`scripts/check.sh` or `scripts/gates.sh`, and before and after)
-21
View File
@@ -1,21 +0,0 @@
# GitHub has no switch to disable pull requests, so this closes them on arrival.
# Uses pull_request_target (base-repo context, no PR code checked out) so it can
# comment and close on PRs from forks.
name: close-prs
on:
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
jobs:
close:
runs-on: ubuntu-latest
steps:
- run: |
gh pr comment "$PR" --repo "$REPO" --body \
"Thanks for the interest! benilla is a solo project and isn't taking pull requests for now — please bring bug reports and ideas to the Discord instead: https://discord.gg/wJSJx467G4"
gh pr close "$PR" --repo "$REPO"
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
+25 -3
View File
@@ -4,18 +4,40 @@
# Cargo.lock IS committed (this workspace produces binaries).
# --- Blizzard-owned content: NEVER commit ---
# A legally-installed 1.12.1 client, read at runtime; not redistributed.
# The user's legally-installed 1.12.1 client. Read at runtime; not redistributed.
/WoW
# Cached / extracted assets derived from an install.
# Cached / extracted assets derived from the user's install.
/extracted/
/out/
# Real client files used as test fixtures (document expected paths, do not commit).
**/tests/fixtures/
# Profiling output (`--features bevy/trace_chrome`).
# --- benilla's own local state ---
# The one folder benilla persists into: config.toml, macros/, bindings/, saved-variables.lua,
# camera/. A dev build resolves it to the main checkout (so linked worktrees share one); a
# release build puts it beside the binary — which is why it is not called `benilla/`: that name
# collides with the binary of the same name. Machine-local by definition — `crate::local_state`
# is the only thing in the tree that may write it.
/benilla-config
# The account a scripted run from this checkout logs in as (scripts/probe-identity.sh and the
# client's account guard read it): WOW_USER=, WOW_PASS=, WOW_CHAR=, one per line. A credential.
/.probe-identity
# Profiling output: `--features bevy/trace_chrome` drops a multi-GB flame chart in the cwd, which
# otherwise leaves the checkout "dirty".
trace-*.json
# OS / editor cruft
.DS_Store
*.swp
/.idea/
# Claude Code: settings and skills under .claude/ are each person's own.
/.claude/
# Python bytecode from the `scripts/` tooling.
__pycache__/
# The vanilla addon corpus the UI engine's real-addon tests read (third-party content): a symlink
# beside `WoW`, or `BENILLA_ADDON_CORPUS`.
/wow-addons-vanilla
+17
View File
@@ -0,0 +1,17 @@
# benilla
A from-scratch World of Warcraft 1.12.1 client in Rust and Bevy. The reference client is the
spec: benilla is a faithful, modern implementation of it, not a place to get creative.
Read, in this order:
1. `docs/METHOD.md` — the rules and how we work. Binding for every session.
2. `docs/MAP.md` — what is built right now, generated from the code. Orient here before
touching anything.
3. `docs/CONTRIBUTING.md` — what gets in and how a change is judged.
The rest of the root is code and data: `crates/` the workspace, `reference/` the 1.12 name
catalogues the tests check against, `scripts/` the gates and instruments, `third_party/`
vendored code.
@docs/METHOD.md
Generated
-2
View File
@@ -513,7 +513,6 @@ dependencies = [
"benilla-formats",
"benilla-protocol",
"benilla-ui",
"benilla-wmo",
"benilla-world",
"bevy",
"bevy_egui",
@@ -674,7 +673,6 @@ dependencies = [
"bevy",
"bytemuck",
"image",
"libc",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
+24 -83
View File
@@ -8,33 +8,18 @@ edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["sam <samuv.eth@gmail.com>"]
# Shared pins live here; promote a dep the moment two crates pin the same version (the policy
# since day one, enacted at the 0530 debt sweep). Per-crate feature additions stay in the crate.
# `image` deliberately stays per-crate: its default-features/feature sets differ per consumer,
# and a workspace default-features choice can't be subtracted from downstream.
# **Lint policy** (decision 2266 §C4). `too_many_arguments` is a readability lint for plain
# functions; on a Bevy system the parameter list IS the scheduler's dependency declaration, and
# a system that reads eight resources has eight parameters by construction. The lint had fired
# 364 times in this workspace and been allowed 364 times, 281 of them on systems — a lint whose
# every verdict is overridden is not a gate, it is a tax on adding a system. Allowed once here;
# the per-site attributes are gone. `type_complexity` stays on: it fires on shapes a type alias
# or a `SystemParam` bundle genuinely fixes.
# A version two crates share is pinned in `[workspace.dependencies]`; feature additions stay per
# crate. `image` stays per-crate: its consumers differ on default features, and a member cannot
# turn off default features that a workspace entry enables.
# `too_many_arguments` is off: a Bevy system's parameter list is its dependency declaration.
[workspace.lints.clippy]
too_many_arguments = "allow"
[workspace.dependencies]
anyhow = "1.0.102"
# 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's default features spelled out without `audio`: `2d`, `3d` and `ui` each pull `bevy_audio`
# and `vorbis`, and the client mixes through kira. Add a feature by name, never `2d`/`3d`/`ui`; a
# plugin that takes bevy with its default features brings the audio stack back too.
bevy = { version = "0.18.1", default-features = false, features = [
"default_app",
"default_platform",
@@ -45,88 +30,44 @@ bevy = { version = "0.18.1", default-features = false, features = [
"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.
# For the `WOW_GPU_MS` meter's query set, which bevy 0.18 does not wrap; must match bevy's wgpu.
wgpu = "27"
clap = { version = "4.6.1", features = ["derive"] }
sha1 = "0.10.6"
# The WoW format + protocol stack is fully in-repo (decision 0021), so there are no warcraft-rs /
# wow_messages crates (or forks of them) anywhere in the tree. That is still true and is why this
# 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 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 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).
# lua-src builds the addon VM's Lua 5.1.5, patched to accept exactly the 1.12 client's grammar;
# the hunks and the command that checks them: `third_party/lua-src/BENILLA.md`.
[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 0.12.1, patched to compute spatial gains and the volume tween per output chunk and
# interpolate them, not per sample frame (`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.
# opt-level 3 on deps (the standard Bevy fix) makes debug builds playable while our own crates stay
# lightly optimized for fast incremental rebuilds. One-time cost: deps recompile optimized the first time.
# Dependencies at opt-level 3 in dev: at opt-level 0, avian/parry collision drops a run to <1 fps.
[profile.dev.package."*"]
opt-level = 3
# Our own crates at opt-level 1, not 0: Bevy's generic hot paths (material specialization scans,
# query par_for_each closures) monomorphize INTO our crate, so at opt 0 they run at debug codegen
# speed over every resident entity — measured at Stormwind as the difference between ~45 fps and a
# pinned 60 (decision 0362). Warm incremental rebuild cost measured at ~zero. NB: FPS probe numbers
# in decisions ≤ 0361 were taken at opt 0 and are not comparable with post-0362 probes.
# Our crates at opt-level 1: Bevy's generic hot paths monomorphize into them, and at opt-level 0
# Stormwind drops from a pinned 60 fps to ~45; the warm incremental rebuild cost is ~0.
[profile.dev]
opt-level = 1
# No debuginfo trimming: `debug = "line-tables-only"` was A/B-measured (decision 0192) at zero
# recurring win on this stack (macOS unpacked split-debuginfo; sccache; fast ld) — not worth
# invalidating every warm target and the sccache cache for.
# No debuginfo trimming: `debug = "line-tables-only"` measures no recurring win on macOS + sccache.
# Shipping-grade optimization, A/B-graded at the pin (decision 0736): −1.0 cpu_ms (−5 %) vs
# default release, flat at the empty-map and bare-Bevy floors. The campaign keeps grading on
# default `release` — same-day A/B is the law, and fat LTO's link time would tax every
# iteration for a constant offset. Build with `--profile ship` for actual releases (add
# `RUSTFLAGS=-Ctarget-cpu=native` for a machine-local binary; not set here — it isn't portable).
# The shipping build (`--profile ship`): fat LTO measures -1.0 cpu_ms (-5 %) against `release`,
# which stays the measured profile because fat LTO's link time would tax every iteration.
# `-Ctarget-cpu=native` is not set: it is not portable.
[profile.ship]
inherits = "release"
lto = "fat"
codegen-units = 1
# The director's play loop (decision 1157): `release` plus incremental compilation. `benilla-app` is one crate of
# ~243k lines, and release is non-incremental by cargo's default, so a one-line edit re-optimizes
# all of it — measured 2m47s (43s frontend, ~121s LLVM at opt-level 3) against a 1.8s stamp rebuild
# and a 2.5s no-op. Incremental caches both halves per codegen unit, so an edit pays for what it
# touched. Cargo applies `incremental` to workspace members ONLY (verified: `target/debug/incremental`
# holds our 16 crates and zero registry deps), so the ~760 dependencies stay non-incremental and
# sccache-cacheable — 0192's wrapper and the cold-slot build are untouched.
#
# Why a separate profile rather than flipping `release`: incremental raises the codegen-unit count
# and drops cross-unit inlining, which is a real codegen difference. The FPS campaign grades on
# default `release` (0736) and same-day A/B is the law, so the graded profile must not quietly
# change under it. Run it as `cargo play` (the alias in `.cargo/config.toml`).
# `release` plus incremental, run as `cargo play`: under `release` a one-line edit to
# `benilla-app` costs 2m47s. Incremental applies to workspace members only, so dependencies stay
# sccache-cacheable; it changes codegen, so it lives here and not in the measured `release`.
[profile.play]
inherits = "release"
incremental = true
# ThinLTO OFF, and it is not a performance choice -- it is the fix for rustc #86049. `release`
# implies thin-LOCAL LTO (ThinLTO across a crate's own codegen units), which promotes each CGU's
# internal anonymous constants to external symbols named `anon.<hash>.N.llvm.<moduleid>`. Under
# `incremental` some CGUs come back from the cache carrying names minted in an earlier compile,
# so a promoted reference finds no definition and `cc` fails with "symbol(s) not found for
# architecture arm64" -- an intermittent link failure with no source-level cause, cleared only by
# a full rebuild. Measured on the 2026-08-29 occurrence: 7 poisoned CGUs in `benilla-formats`, 48
# unresolved symbols. `lto = "off"` disables the promotion step entirely (verified: the play rlib
# goes from 381 undefined `anon.*.llvm.*` symbols to 0). `release` and `ship` are UNTOUCHED -- they
# are non-incremental, cannot hit this, and stay the graded profiles (0736). Decision 1698.
# Not for speed: under `incremental`, thin-local LTO leaves promoted `anon.*.llvm.*` symbols
# unresolved and the link fails intermittently (rustc #86049).
lto = "off"
+1 -1
View File
@@ -2,7 +2,7 @@
A dynamic light and shadow system for [benilla](https://github.com/samwhosung/benilla), the Rust +
Bevy reimplementation of the 1.12.1 client. This branch is upstream benilla (merged at
`edec5b22`) plus the lighting work, and nothing else. It needs no data changes: everything is
`fd386e75`) plus the lighting work, the enhanced water module and volumetric fog, and nothing else. It needs no data changes: everything is
derived from what a 1.12 install already carries (WMO `MOLT` lights, `MOCV` vertex colour, portals,
M2 particle emitters, `Light.dbc`).
+23 -21
View File
@@ -8,12 +8,6 @@
</p>
</div>
> [!IMPORTANT]
> **Issues and pull requests are closed here.** benilla is a solo project developed in a private
> tree; this repo is its export, published as squashed snapshots, so a PR here has nothing to land
> on. The best way to contribute is to join the [Discord](https://discord.gg/wJSJx467G4) and report
> the bugs you find. Questions and ideas are welcome in the same place.
benilla speaks the original 1.12.1 protocol, so it connects to any server the real client could,
and reads its game data at runtime from your own 1.12.1 install. Every file format and the network
protocol are implemented from scratch, with no original client code, no third-party WoW crates,
@@ -34,12 +28,12 @@ and no bundled game assets.
modes from slow fall to roots, a follow camera with collision, boats, zeppelins and taxi flights.
- **Networking:** SRP6 auth through world-session crypto, the object mirror into the ECS, and live
wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank,
loot, the auction house and PvP honor.
- **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and
character screens through the full HUD, the classic windows (guild, macros and key bindings
included), chat, nameplates, floating combat text and tooltips; third-party addons load from
a `benilla-config/AddOns/` folder beside the executable (partial: AtlasLoot and Bagnon run,
see below).
loot, the auction house, PvP honor and battlegrounds.
- **UI:** a from-scratch FrameXML + Lua engine that runs the client's own stock interface off
your install's patch chain, from the login and character screens through the full HUD, the
classic windows (guild, macros and key bindings included), chat, nameplates, floating combat
text and tooltips; third-party addons load from a `benilla-config/AddOns/` folder beside the
executable (partial: AtlasLoot and Bagnon run).
- **Combat:** melee on the faithful swing law, ranged and Auto Shot, casting with GCD and
cooldowns, combo points, crowd control that really holds you, and the spell visual pipeline.
- **Audio:** music, ambience and SFX under the client's own selection and crossfade rules, with
@@ -51,10 +45,8 @@ benilla is done when a 1.12.1 player can do everything here that they could in t
client, it looks and feels the same, and it runs from a download on Windows, Linux and macOS.
No dates; the order is what is likely, not a promise.
- Battlegrounds, then the long tail of small features that separates a working client from a
finished one.
- The long tail of small features that separates a working client from a finished one.
- Addons, options and performance, ongoing.
- The no-brainer fixes from VanillaFixes, SuperWoW and the like.
- Playable downloads for Windows, Linux and macOS. Linux first.
Not planned: other expansions or client versions, Warden (anticheat).
@@ -62,8 +54,10 @@ Not planned: other expansions or client versions, Warden (anticheat).
## Running it
You need a **1.12.1 (build 5875) client install** for game data, a vanilla server to connect to,
and stable Rust. Any 1.12.1 core works; [vmangos](https://github.com/vmangos/core) is what
development runs against, and cMaNGOS and the rest speak the same protocol.
stable Rust and a C compiler (the Lua is built from source; on macOS the Xcode command line
tools, on Linux the ALSA and udev development packages). Any 1.12.1 core works;
[vmangos](https://github.com/vmangos/core) is what development runs against, and cMaNGOS and
the rest speak the same protocol.
```sh
WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla
@@ -74,6 +68,12 @@ at any IP or hostname, appending the auth port if yours is remapped
(`WOW_HOST=play.example.com:5000`). Credentials go in at the login screen, or set `WOW_USER` /
`WOW_PASS` to skip it.
## Contributing
Issues and pull requests are open. [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) says what gets
in and how a change is judged. Bugs, questions and ideas are welcome on the
[Discord](https://discord.gg/wJSJx467G4) too.
---
Early inspiration and file format guidance came from the
@@ -82,9 +82,11 @@ Early inspiration and file format guidance came from the
benilla is an independent fan project, not affiliated with or endorsed by Blizzard Entertainment.
It ships **no Blizzard content** — no art, models, sounds, maps, MPQ contents or FrameXML; you
provide your own legally obtained 1.12.1 client. The interface code under
`crates/benilla-app/assets/ui/` is ours, written to the client's own layout and API names so that
the windows look right and 1.12.1 addons find the names they expect.
provide your own legally obtained 1.12.1 client. The stock interface runs off your own install's
FrameXML at runtime; the handful of files under `crates/benilla-app/assets/ui/` are our own
adapters and developer frames, not copies of it.
World of Warcraft is a trademark of Blizzard Entertainment, Inc. Our own code is licensed under
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option.
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. The two vendored components
under `third_party/` — the kira audio engine, and a Lua 5.1 patched to the 1.12 client's dialect —
keep their own upstream licenses, alongside each.
+1 -1
View File
@@ -3,7 +3,7 @@ name = "benilla-adt"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "ADT terrain-tile reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021"
description = "ADT terrain-tile reader for WoW 1.12.1 (build 5875)"
[lints]
workspace = true
+41 -12
View File
@@ -1,13 +1,11 @@
//! MCAL → one 64×64 RGBA alpha map (R/G/B = the opacity of texture layers 1/2/3; A = 255). Ported
//! from wow-adt's `CombinedAlphaMap` so the output is byte-identical: layers are ingested in MCLY
//! order (skipping the opaque base), each decoded into the next channel; encodings are 4-bit packed
//! (×16), 8-bit raw, or Blizzard RLE (token bit 7 = fill, else copy; low 7 bits = count). `fix_alpha`
//! reconstructs a 64×64 plane from 63×63 source by duplicating the previous pixel at the last row/col.
//! MCAL to one 64×64 RGBA alpha map, ported from wow-adt's `CombinedAlphaMap`: R, G, B are the
//! opacity of texture layers 1, 2, 3 and A is 255. Layers go in MCLY order after the opaque base. A
//! layer is 4-bit packed (×17: the reference packs it into an RGBA4444 texel, read as `n / 15`),
//! 8-bit raw, or RLE (token bit 7 fill, else copy; the low 7 bits are the count).
use crate::McnkChunk;
/// Assembles the per-layer MCAL alpha maps into one 64×64 RGBA buffer (`y`-major, then `x`, then
/// channel R,G,B,A).
/// The combined 64×64 RGBA alpha map, `y`-major.
pub struct CombinedAlphaMap {
map: Vec<u8>, // 64*64*4, [y][x][rgba]
x: usize,
@@ -20,12 +18,13 @@ pub struct CombinedAlphaMap {
const W: usize = 64;
impl CombinedAlphaMap {
/// Construct and ingest `chunk`'s alpha layers. `has_big_alpha`/`fix_alpha` select the uncompressed
/// encoding width and the 63→64 edge fix (vanilla: `false`, `true`).
/// Decode `chunk`'s alpha layers. `has_big_alpha` makes uncompressed layers 8-bit, and
/// `fix_alpha` fills the last row and column from their neighbours (a 63×63 source); vanilla
/// is `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.as_chunks_mut::<4>().0 {
px[3] = 255; // A unused, set opaque for tool visibility (matches wow-adt)
px[3] = 255; // A is unused; opaque, so a dump shows the channels
}
let mut s = Self {
map,
@@ -76,10 +75,10 @@ impl CombinedAlphaMap {
const PACKED: usize = W * W / 2;
if offset + PACKED <= raw.len() {
for &p in &raw[offset..offset + PACKED] {
if !self.set_next((p & 0x0F) * 16) {
if !self.set_next((p & 0x0F) * 17) {
break;
}
if !self.set_next(((p >> 4) & 0x0F) * 16) {
if !self.set_next(((p >> 4) & 0x0F) * 17) {
break;
}
}
@@ -171,3 +170,33 @@ impl CombinedAlphaMap {
self.y = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn blank() -> CombinedAlphaMap {
CombinedAlphaMap {
map: vec![0; W * W * 4],
x: 0,
y: 0,
layer: 0,
has_big_alpha: false,
fix_alpha: true,
}
}
/// The reference packs a 4-bit weight into an RGBA4444 texel (64×64 `0x6b03d0`, 32×32
/// `0x6b08d0`), read as `n / 15`, so nibble 15 is full coverage.
#[test]
fn four_bit_alpha_reads_as_n_over_15() {
let mut m = blank();
// Low nibble first: texel 0 = 0xF, texel 1 = 0x8.
m.ingest_small(&[0x8F; W * W / 2], 0);
assert_eq!(m.get(0, 0, 0), 255, "nibble 15 is full coverage");
assert_eq!(m.get(1, 0, 0), 136, "nibble 8 is 8/15");
let mut z = blank();
z.ingest_small(&[0x00; W * W / 2], 0);
assert_eq!(z.get(0, 0, 0), 0);
}
}
+64 -136
View File
@@ -1,27 +1,11 @@
//! An ADT terrain-tile reader for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-adt`
//! (decision 0021).
//!
//! Vanilla ADTs are monolithic, chunked (`IFF`-style: a 4-char magic — stored **reversed** on disk —
//! plus a `u32` size, then payload). The root file holds `MTEX` (textures), `MMDX`/`MMID` (M2 paths),
//! `MWMO`/`MWID` (WMO paths), `MDDF`/`MODF` (placements), and 256 `MCNK` terrain chunks. Each `MCNK` is
//! a 128-byte header followed by sub-chunks (`MCVT` heights, `MCNR` normals, `MCLY` layers, `MCAL`
//! alpha, `MCSH` shadow, `MCLQ` liquid), which we locate by **scanning** the chunk payload for their
//! magics (robust, and identical to the header-offset data for vanilla).
//!
//! The shape mirrors what the renderer's terrain/liquid mesher consumes; quirks the consumer
//! compensates for (the MCNR `[x,z,y]` byte order, the split predominant-texture/noEffectDoodad
//! grids) are preserved deliberately. Proven against `wow-adt` over real ADTs during the decision-0021
//! migration (oracle test in git history); the `benilla-formats` terrain golden tests pin the meshing
//! end-to-end on every run.
//!
//! Byte access goes through `benilla-bytes` (decision 0064): every read is bounds-checked and a
//! truncated chunk/record is a typed [`Error::Truncated`], never a panic.
//! A reader for 1.12.1's monolithic root ADTs: IFF chunks (a reversed magic, a `u32` size, the
//! payload) holding the asset names, the placements and 256 `MCNK` terrain chunks.
use std::io::Cursor;
use benilla_bytes::{chunks, ByteExt};
/// A terrain vertex normal — stored on disk as `x, z, y` signed bytes.
/// A terrain vertex normal: three signed bytes, named `x, z, y` in disk order.
#[derive(Debug, Clone, Copy)]
pub struct VertexNormal {
pub x: i8,
@@ -30,8 +14,7 @@ pub struct VertexNormal {
}
impl VertexNormal {
/// `[x/127, y/127, z/127]` — i.e. on-disk `[b0, b2, b1]` / 127 (kept bit-identical to wow-adt; the
/// consumer cancels the z/y swap).
/// `[x, y, z] / 127`, which is disk bytes `[b0, b2, b1]`; the terrain mesher swaps them back.
pub fn to_normalized(&self) -> [f32; 3] {
[
f32::from(self.x) / 127.0,
@@ -41,19 +24,19 @@ impl VertexNormal {
}
}
/// MCVT — 145 heights (9×9 outer + 8×8 inner, stride-17 interleave).
/// MCVT: 145 heights (9×9 outer + 8×8 inner, stride-17 interleave).
#[derive(Debug, Clone)]
pub struct McvtChunk {
pub heights: Vec<f32>,
}
/// MCNR — one normal per MCVT vertex.
/// MCNR: one normal per MCVT vertex.
#[derive(Debug, Clone)]
pub struct McnrChunk {
pub normals: Vec<VertexNormal>,
}
/// MCLY per-layer flags (we only need the alpha-compression bit).
/// MCLY per-layer flags.
#[derive(Debug, Clone, Copy)]
pub struct MclyFlags {
pub value: u32,
@@ -66,7 +49,7 @@ impl MclyFlags {
}
}
/// MCLY — one texture layer (16 bytes on disk).
/// MCLY: one texture layer (16 bytes on disk).
#[derive(Debug, Clone)]
pub struct MclyLayer {
pub texture_id: u32,
@@ -75,19 +58,19 @@ pub struct MclyLayer {
pub effect_id: u32,
}
/// MCLY — the chunk's texture layers (0–4; layer 0 is the opaque base).
/// MCLY: the chunk's texture layers (0–4; layer 0 is the opaque base).
#[derive(Debug, Clone)]
pub struct MclyChunk {
pub layers: Vec<MclyLayer>,
}
/// MCAL — raw alpha-map bytes (indexed per layer via [`MclyLayer::offset_in_mcal`]).
/// MCAL: raw alpha-map bytes (indexed per layer via [`MclyLayer::offset_in_mcal`]).
#[derive(Debug, Clone)]
pub struct McalChunk {
pub data: Vec<u8>,
}
/// MCSH — a 64×64 1-bit baked shadow map (512 bytes).
/// MCSH: a 64×64 1-bit baked shadow map (512 bytes).
#[derive(Debug, Clone)]
pub struct McshChunk {
pub shadow_map: Vec<u8>,
@@ -104,11 +87,8 @@ impl McshChunk {
}
}
/// One MCLQ liquid vertex (8 bytes: 4 union bytes + an absolute height).
///
/// The leading 4 bytes are a **union whose meaning is the block's liquid type** — water/ocean spend
/// them on a depth byte + flow/foam, magma/slime on a `u16` texture-coordinate pair. Both readers are
/// below; which one applies is the caller's decision, from the cell nibble.
/// One MCLQ liquid vertex, 8 bytes: 4 union bytes and an absolute height. The union follows the
/// cell's liquid type: a depth byte and flow for water and ocean, a UV pair for magma and slime.
#[derive(Debug, Clone, Copy)]
pub struct LiquidVertex {
pub union_data: [u8; 4],
@@ -116,19 +96,14 @@ pub struct LiquidVertex {
}
impl LiquidVertex {
/// The depth byte (union byte 0) — drives the water depth-swatch on the render side. Water and
/// ocean blocks only; on a magma block these bytes are [`Self::texcoords`] instead.
/// The water or ocean depth byte (union byte 0).
pub fn depth_byte(&self) -> u8 {
self.union_data[0]
}
/// The authored `(s, t)` texture-coordinate pair — two little-endian `u16`s over the same 4
/// union bytes. **Magma blocks only**: the reference's lava vert-fill is single-stage and reads
/// its `tc0` straight from here, where water/ocean instead write a UV into the depth-ramp
/// texture (VERIFIED bit-exact `WoW.exe 0x68d890` `liquid_render_verts`, emulation-diffed in
/// wow-re `crates/terrain`: `u = (s as i32 as f64 · 3/256) as f32`, same for `t`). The field is
/// authored world-continuous — a chunk's east edge repeats its neighbour's west edge exactly —
/// so lava tiles seamlessly across MCNK borders.
/// A magma block's authored `(s, t)`, two little-endian `u16`s the reference's lava fill takes
/// as its UV (`0x68d890`: `u = (s as i32 as f64 · 3/256) as f32`, same for `t`). The field is
/// world-continuous, so lava tiles seamlessly across MCNK borders.
pub fn texcoords(&self) -> [u16; 2] {
[
u16::from_le_bytes([self.union_data[0], self.union_data[1]]),
@@ -137,51 +112,33 @@ impl LiquidVertex {
}
}
/// One MCLQ liquid **block** — a 9×9 absolute-height grid + an 8×8 cell-flag grid, `0x324` bytes.
///
/// An MCNK carries **one block per set liquid header bit** (bits 2–5), packed back to back and with
/// absent bits consuming nothing (VERIFIED wow-re `system/terrain/scratch/adt-format.md`, the cursor
/// walk at `0x6af7a3`–`0x6af7cb`). Most liquid chunks set exactly one bit; **28 shipped Azeroth
/// chunks set two** — a river *and* the sea, at a river mouth — and carry two blocks at different
/// heights (measured: `sizeMCLQ` 1616 = 8 + 2·`0x324`, block 0 the stream at z≈5.0, block 1 the
/// ocean at z=0). Reading only the first is how the sea goes missing there.
///
/// **No liquid type here on purpose.** The header-bit→type ordering (bit2 river / bit3 ocean /
/// bit4 magma / bit5 slime) is *INFERRED* upstream — never byte-proven — while the per-cell flag low
/// nibble IS the type, VERIFIED at both consumers (`0x6ba970` `and al,0xf`, `0x68d9b0`). So this
/// reader takes only the block *count* from the flags and leaves the type to whoever reads
/// [`Self::tile_flags`]. (The old `LiquidType::from_mcnk_flags` priority guess labelled all 28
/// two-bit chunks "ocean" while handing back the river block's bytes.)
/// One MCLQ liquid block: a 9×9 absolute-height grid and an 8×8 cell-flag grid. An MCNK carries
/// one per set liquid header bit, back to back (`0x6af7a3`–`0x6af7cb`): two at the 28 shipped river
/// mouths. The liquid type is each cell's flag nibble (`0x6ba970`, `0x68d9b0`), not a header bit.
#[derive(Debug, Clone)]
pub struct MclqChunk {
pub min_height: f32,
pub max_height: f32,
pub vertices: Vec<LiquidVertex>,
/// Per-cell flags, row-major 8×8. Low nibble = liquid type (`0xf` = dry/hole); `0x80` shared.
/// `0x40` ("fishable") is carried but deliberately unread: its only compiled reader, the
/// reference's fishable query `0x69b5d0`, is a zero-caller dead island — fishability is the
/// server's verdict (`SPELL_FAILED_NOT_FISHABLE` passthru). Decision 1825.
/// Per-cell flags, row-major 8×8: the low nibble is the liquid type (`0xf` dry), `0x80`
/// shared. `0x40`, fishable, is unread: the reference's only reader (`0x69b5d0`) has no
/// caller, and the server decides fishability.
pub tile_flags: [u8; 64],
}
/// MCNK header flag **bit 1** — the chunk a mover may not enter (the "impassable" band that walls
/// Searing Gorge and the other mountain rims off from their neighbours).
///
/// The *producer* is VERIFIED per-instruction in wow-re (`system/terrain/scratch/adt-format.md`,
/// the MCNK header walk: `hdr+0x00` bit1 → `chunk+0xc |= 0x40`); the *name* is wow-re's INFERRED
/// label for that bit. Consuming it is decision 1266 / report B129.
/// MCNK header flag bit 1: a chunk a mover may not enter, the bands along mountain rims such as
/// Searing Gorge's. The reference's header walk copies it to its chunk flag `0x40` (`0x6af5f0`).
pub const MCNK_IMPASSABLE: u32 = 0x2;
/// The fields of the 128-byte MCNK header the renderer reads. `pred_tex`/`no_effect_doodad`/
/// `unknown_8bytes` mirror wow-adt's (mis)split of the predominant-texture + noEffectDoodad region,
/// which the consumer reconstructs — kept identical on purpose.
/// The MCNK header fields the renderer reads. The names mislead: `pred_tex` and `no_effect_doodad`
/// are the two halves of the predominant-texture map at `+0x40`, and `unknown_8bytes` is the real
/// noEffectDoodad at `+0x50`; the consumer reassembles them.
#[derive(Debug, Clone)]
pub struct McnkHeader {
pub flags: u32,
pub index_x: u32,
pub index_y: u32,
/// `AreaTable.dbc` id of this chunk (header +0x34) — the zone/subzone the player is standing
/// in; drives zone music/ambience/reverb selection (decision 0070).
/// `AreaTable.dbc` id of the chunk's zone or subzone (`+0x34`).
pub area_id: u32,
pub holes_low_res: u16,
pub pred_tex: [u8; 8],
@@ -191,9 +148,7 @@ pub struct McnkHeader {
}
impl McnkHeader {
/// Is this chunk flagged [`MCNK_IMPASSABLE`]? The flag is a header bit, so its granularity is
/// the whole 33.333 yd chunk; in the shipped data the set chunks form contiguous ribbons along
/// zone rims (Searing Gorge's is 48 chunks of `Azeroth_33_44` alone).
/// Whether the raw header sets [`MCNK_IMPASSABLE`], which covers the whole 33.333 yd chunk.
pub fn impassable(&self) -> bool {
self.flags & MCNK_IMPASSABLE != 0
}
@@ -208,12 +163,11 @@ pub struct McnkChunk {
pub layers: Option<MclyChunk>,
pub alpha: Option<McalChunk>,
pub shadow: Option<McshChunk>,
/// The chunk's MCLQ liquid blocks — **one per set liquid header bit**, in on-disk order (see
/// [`MclqChunk`]). Empty on a dry chunk; two at a river mouth.
/// The MCLQ liquid blocks in disk order: none on a dry chunk, two at a river mouth.
pub liquids: Vec<MclqChunk>,
}
/// MDDF — a placed M2 doodad (36 bytes).
/// MDDF: a placed M2 doodad (36 bytes).
#[derive(Debug, Clone)]
pub struct DoodadPlacement {
pub name_id: u32,
@@ -224,7 +178,7 @@ pub struct DoodadPlacement {
pub flags: u16,
}
/// MODF — a placed WMO (64 bytes).
/// MODF: a placed WMO (64 bytes).
#[derive(Debug, Clone)]
pub struct WmoPlacement {
pub name_id: u32,
@@ -233,8 +187,8 @@ pub struct WmoPlacement {
pub rotation: [f32; 3],
pub flags: u16,
pub doodad_set: u16,
/// `WMOAreaTable.NameSetID` selector — which naming/audio variant of the building this
/// placement is (Northshire Abbey vs Tyr's Hand Abbey are one WMO, different name sets).
/// `WMOAreaTable.NameSetID`: which name and audio variant of the WMO this placement is
/// (Northshire Abbey and Tyr's Hand Abbey are one WMO).
pub name_set: u16,
}
@@ -249,8 +203,7 @@ pub struct RootAdt {
pub mcnk_chunks: Vec<McnkChunk>,
}
/// Either a vanilla monolithic root ADT (all we produce) — kept as an enum to mirror the consumer's
/// `ParsedAdt::Root(..)` match.
/// A parsed ADT; vanilla has only the monolithic root.
#[derive(Debug, Clone)]
pub enum ParsedAdt {
Root(Box<RootAdt>),
@@ -274,8 +227,7 @@ impl std::error::Error for Error {}
type Result<T> = std::result::Result<T, Error>;
/// Split a NUL-separated string blob into a `Vec<u32>` of start offsets paired with strings (used to
/// resolve MMDX/MWMO via MMID/MWID offsets).
/// The NUL-terminated string at `offset`: an MMDX or MWMO name by its MMID or MWID offset.
fn cstring_at(blob: &[u8], offset: usize) -> String {
let end = blob[offset.min(blob.len())..]
.iter()
@@ -285,7 +237,7 @@ fn cstring_at(blob: &[u8], offset: usize) -> String {
String::from_utf8_lossy(&blob[offset.min(blob.len())..end]).into_owned()
}
/// Parse a vanilla monolithic ADT. (`cursor` position is ignored; the file starts at the first chunk.)
/// Parse a vanilla monolithic ADT; the cursor's position is ignored.
pub fn parse_adt(cursor: &mut Cursor<&[u8]>) -> Result<ParsedAdt> {
let b: &[u8] = cursor.get_ref();
@@ -312,7 +264,7 @@ pub fn parse_adt(cursor: &mut Cursor<&[u8]>) -> Result<ParsedAdt> {
}
}
// Resolve model/WMO paths via the offset tables (the order MDDF/MODF `name_id` indexes).
// MDDF/MODF `name_id` indexes the MMID/MWID offset tables.
let models = mmid
.iter()
.map(|&o| cstring_at(&mmdx, o as usize))
@@ -340,7 +292,6 @@ fn split_strings(blob: &[u8]) -> Vec<String> {
.collect()
}
/// `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.as_chunks::<4>()
.0
@@ -388,18 +339,17 @@ fn read_modf(data: &[u8]) -> Result<Vec<WmoPlacement>> {
r.f32_at(24).ok_or(Error::Truncated("MODF"))?,
r.f32_at(28).ok_or(Error::Truncated("MODF"))?,
],
// 0x20..0x38 = bounding box (6 f32); then:
// 0x20..0x38: the bounding box (6 f32), unread.
flags: r.u16_at(56).ok_or(Error::Truncated("MODF"))?,
doodad_set: r.u16_at(58).ok_or(Error::Truncated("MODF"))?,
name_set: r.u16_at(60).ok_or(Error::Truncated("MODF"))?,
// 0x3E scale u16 — unused.
// 0x3E: a u16, unread.
});
}
Ok(out)
}
/// Parse one MCNK: a 128-byte header, then sub-chunks located by scanning the payload for their
/// (reversed) magics.
/// Parse one MCNK: the 128-byte header, then the sub-chunks at the header's offsets.
fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
if data.len() < 128 {
return Err(Error::Truncated("MCNK header"));
@@ -443,14 +393,9 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
liquids: Vec::new(),
};
// Locate sub-chunks by the MCNK header offsets (the authoritative method — sequential scanning
// breaks when sub-chunks aren't contiguous, e.g. AhnQiraj). MCVT/MCNR offsets live in the
// `multipurpose_field` (0x14/0x18); the rest are explicit header fields. The offset base differs
// across sources (relative to the MCNK magic vs the MCNK data), so we auto-detect per sub-chunk by
// validating the (reversed) magic at both candidate positions. Returns the payload `(start, size)`.
// `ofs` comes straight off a (possibly corrupt) file's header, so both the "minus 8" candidate
// and the 8-byte header read use checked/bounds-checked arithmetic (`checked_sub`/`bytes_at`/
// `u32_at`) — a wrapped or out-of-range offset yields `None`, never a panic.
// Sub-chunks sit at the header's offsets, not in sequence (AhnQiraj's are not contiguous).
// An offset may count from the MCNK magic or from its data, so the reversed magic is checked
// at both; yields the payload's `(start, size)`.
let locate = |ofs: u32, magic: &[u8; 4]| -> Option<(usize, usize)> {
if ofs == 0 {
return None;
@@ -464,8 +409,7 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
}
None
};
// `start` always comes from a `locate` hit (bounds-proven above); `len` is attacker-controlled,
// so the end is saturating before clamping to the buffer.
// `locate` proves `start` in bounds; `len` is unvalidated, so the end saturates, then clamps.
let slice = |start: usize, len: usize| &data[start..start.saturating_add(len).min(data.len())];
if let Some((p, n)) = locate(
@@ -517,8 +461,8 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
}
chunk.layers = Some(MclyChunk { layers });
}
// MCAL's data length is the header's `size_alpha`, NOT the sub-chunk's declared size (they differ
// on some files, e.g. AhnQiraj — wow-adt reads `size_alpha`).
// MCAL's length is the header's `size_alpha`, not the sub-chunk's own size: they differ on some
// files (AhnQiraj).
if let Some((p, _)) = locate(
h.u32_at(0x24).ok_or(Error::Truncated("MCNK header"))?,
b"LACM",
@@ -540,8 +484,7 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
shadow_map[..k].copy_from_slice(&sub[..k]);
chunk.shadow = Some(McshChunk { shadow_map });
}
// MCLQ, like MCAL, takes its length from the MCNK header (`size_liquid`): the MCLQ sub-chunk's
// own declared size is always 0.
// MCLQ's length is the header's `size_liquid` too: its own declared size is always 0.
if let Some((p, _)) = locate(
h.u32_at(0x60).ok_or(Error::Truncated("MCNK header"))?,
b"QLCM",
@@ -555,14 +498,12 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
/// Bytes per MCLQ block: `{f32 min, f32 max, 81×8B verts, 64B cell flags, u32 nFlow, 2×40B flow}`.
const MCLQ_BLOCK: usize = 0x324;
/// Liquid header bits (2–5) — their **count** is the number of packed MCLQ blocks. Only the count is
/// read: the bit→type ordering is INFERRED, the cell nibble is VERIFIED (see [`MclqChunk`]).
/// Liquid header bits 2–5: how many are set is how many MCLQ blocks there are.
const MCNK_LIQUID_BITS: u32 = 0x3c;
/// Read the packed MCLQ blocks — one per set liquid header bit, back to back (VERIFIED wow-re
/// `adt-format.md`). Stops early on a block the payload can't cover, so a truncated tail degrades to
/// the blocks that *are* whole rather than to nothing. A chunk with an MCLQ sub-chunk but no liquid
/// bit set still yields one block, matching the pre-multi-block reader.
/// Read the packed MCLQ blocks, stopping at the first one the payload cannot hold. A chunk with an
/// MCLQ sub-chunk but no liquid bit set still yields one block; the reference creates one per set
/// bit only (`0x6af760`).
fn read_mclq_blocks(sub: &[u8], mcnk_flags: u32) -> Vec<MclqChunk> {
let want = (mcnk_flags & MCNK_LIQUID_BITS).count_ones().max(1) as usize;
let mut out = Vec::with_capacity(want);
@@ -578,9 +519,8 @@ fn read_mclq_blocks(sub: &[u8], mcnk_flags: u32) -> Vec<MclqChunk> {
out
}
/// `None` (not an error) below the minimum size: too-short MCLQ data is treated as "no liquid",
/// matching the pre-0064 reader — a real-data tolerance, not a hardening carve-out. (The minimum is
/// the *read* span, short of the full [`MCLQ_BLOCK`]: the trailing flow vectors are unread.)
/// Too-short MCLQ data is no liquid, not an error. The minimum stops short of [`MCLQ_BLOCK`]: the
/// trailing flow data is unread.
fn read_mclq_block(sub: &[u8]) -> Option<MclqChunk> {
const NEED: usize = 8 + 81 * 8 + 64;
if sub.len() < NEED {
@@ -621,9 +561,7 @@ mod tests {
v
}
/// A 128-byte MCNK header with a single MCVT (145 heights) sub-chunk placed right after it,
/// referenced via the "offset relative to the MCNK magic" convention (multipurpose field @0x14
/// pointing at the MCVT chunk's own magic, 128 bytes in).
/// An MCNK header and one MCVT after it, the `0x14` offset pointing at MCVT's magic, 128 in.
fn mcnk_with_mcvt() -> Vec<u8> {
let mut data = vec![0u8; 128];
data[0x14..0x18].copy_from_slice(&128u32.to_le_bytes());
@@ -679,9 +617,7 @@ mod tests {
#[test]
fn partial_trailing_mddf_record_is_dropped_not_a_panic() {
// A record shorter than the 36-byte MDDF stride: `chunks_exact` drops the short remainder
// (a pre-existing, unchanged leniency — every *complete* 36-byte record is still
// bounds-checked via `ByteExt`, so this can never panic or misread).
// Shorter than the 36-byte stride: `as_chunks` drops the remainder.
let b = chunk(b"FDDM", &[0u8; 10]);
let ParsedAdt::Root(root) = parse(&b).expect("parses");
assert!(root.doodad_placements.is_empty());
@@ -695,9 +631,7 @@ mod tests {
#[test]
fn corrupt_mcnk_subchunk_offset_skips_cleanly_never_panics() {
// ofs = 3: `checked_sub(8)` on the second candidate is `None`, and the first candidate (3)
// doesn't land on a valid magic — must yield `None`, not wrap/panic (the verified
// `ofs.wrapping_sub(8)` trap this replaces).
// ofs = 3: the minus-8 candidate underflows and 3 holds no magic.
let mut data = vec![0u8; 128];
data[0x14..0x18].copy_from_slice(&3u32.to_le_bytes());
let b = chunk(b"KNCM", &data);
@@ -716,20 +650,16 @@ mod tests {
fn short_mclq_is_treated_as_no_liquid_not_an_error() {
let mut data = vec![0u8; 128];
data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes()); // MCLQ offset
data[0x64..0x68].copy_from_slice(&4u32.to_le_bytes()); // size_liquid — far short of NEED
data[0x64..0x68].copy_from_slice(&4u32.to_le_bytes()); // size_liquid, far short of NEED
data.extend(chunk(b"QLCM", &[0u8; 4]));
let b = chunk(b"KNCM", &data);
let ParsedAdt::Root(root) = parse(&b).expect("parses");
assert!(root.mcnk_chunks[0].liquids.is_empty());
}
/// **One MCLQ block per set liquid header bit**, packed back to back — the shape that made the
/// sea vanish at 28 shipped river mouths when only the first was read (see [`MclqChunk`]).
/// Two bits set ⇒ two blocks, each keeping its OWN cell flags, so the consumer can tell the
/// river block from the ocean block by nibble.
/// Each block keeps its own cell flags, so the river and the ocean tell apart by nibble.
#[test]
fn a_two_bit_mcnk_yields_two_mclq_blocks_in_disk_order() {
// One block: min/max, 81×8B verts, 64B cell flags, then the flow tail — 0x324 total.
let block = |height: f32, nibble: u8| {
let mut b = vec![0u8; MCLQ_BLOCK];
b[0..4].copy_from_slice(&height.to_le_bytes());
@@ -740,11 +670,11 @@ mod tests {
b[8 + 81 * 8..8 + 81 * 8 + 64].fill(nibble);
b
};
let mut payload = block(5.0, 4); // bit 2 (river) — first on disk
payload.extend(block(0.0, 1)); // bit 3 (ocean) — second
let mut payload = block(5.0, 4); // the river, first on disk
payload.extend(block(0.0, 1)); // the ocean, second
let mut data = vec![0u8; 128];
data[0x00..0x04].copy_from_slice(&0x0cu32.to_le_bytes()); // liquid bits 2 AND 3
data[0x00..0x04].copy_from_slice(&0x0cu32.to_le_bytes()); // liquid bits 2 and 3
data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes());
data[0x64..0x68].copy_from_slice(&((payload.len() + 8) as u32).to_le_bytes());
data.extend(chunk(b"QLCM", &payload));
@@ -759,9 +689,7 @@ mod tests {
assert_eq!(liquids[1].min_height, 0.0);
}
/// The magma union bytes are an authored `(s, t)` u16 pair, little-endian over the same 4 bytes
/// the water blocks spend on a depth byte — VERIFIED `0x68d890` reads them as `u16 → i32`, so
/// `0xffff` is 65535 and never −1.
/// `0x68d890` reads the magma pair as `u16 → i32`, so `0xffff` is 65535, never −1.
#[test]
fn magma_union_bytes_read_as_two_unsigned_u16_texcoords() {
let v = LiquidVertex {
+38 -125
View File
@@ -3,164 +3,85 @@ name = "benilla-app"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "The Bevy app as a library: everything but the build-id stamp, which lives in the `benilla` launcher shim so a commit dirties ~30 lines instead of this crate (decision 0993)"
description = "The Bevy app as a library: everything but the build-id stamp, which lives in the `benilla` launcher so a new commit does not rebuild this crate"
[lints]
workspace = true
[features]
# **The dev/player seam** (decisions 0026 + 1173). Default-ON, so every ordinary `cargo run`,
# capture, probe and gate builds exactly what it built before. `--no-default-features` compiles
# the instruments — the debug panel, the perf HUD, the inspector, the capture harness and the
# probe fleet — physically OUT: not dormant behind a runtime flag, absent. That is the whole
# requirement a player client has of them, and a runtime toggle cannot meet it.
#
# The rule the seam enforces is one-directional: **dev may see anything; nothing may depend on
# dev.** It is enforced by `cargo build -p benilla --no-default-features` in `scripts/gates.sh`,
# not by anyone remembering it — 0026 adopted the rule in June 2026 with no failing build behind
# it, and twenty-four couplings had accumulated by August (1173).
# `dev` compiles in the instruments: the debug panel, perf HUD, inspector, capture harness and
# probes. Nothing may depend on it; `scripts/gates.sh` builds `benilla` with no default features.
default = ["dev"]
#
# The three benilla crates below are taken with `default-features = false` and their `dev`
# re-exported here (1175). Their `dev` is default-on too, so without the round trip a
# `--no-default-features` build gets the feature back through unification — and the binary
# carries the build machine's source-tree path after all. Same trap as the shim's, one level down.
# `benilla-assets`, `-formats` and `-world` come in without default features and their `dev` is
# forwarded here: it is on by default in each, so unification would put it back in a player build.
dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:bevy_egui"]
# Opt-in Tracy profiling: `cargo run --features tracy` streams per-system `info_span!` markers (on the
# hot systems) + render-pass CPU timings to a Tracy capture (decision 0026 — the dev/player seam;
# a `docs/howto/running.md` this once cited never landed).
tracy = ["bevy/trace_tracy"]
# Opt-in Chrome-trace profiling: `cargo run --features trace_chrome` writes a `trace-<ts>.json` of
# every system's per-run span next to the working dir. Tracy's offline twin — it needs no GUI and no
# live capture, so a per-system cost question is answerable from a terminal (and from a run someone
# else recorded). Aggregate it with `scripts/tracesum.py`.
# `--features trace_chrome` writes every system's per-run span to `trace-<ts>.json` in the cwd.
trace_chrome = ["bevy/trace_chrome"]
[dependencies]
anyhow.workspace = true
# OS clipboard access for the UI EditBox copy/cut/paste chords (chat input). Already in the tree via
# bevy_egui; pinned to the same 3.x so there's one copy. Read on the main thread (the UI key feed is
# a NonSend system) — required for the macOS NSPasteboard path. Covers macOS, Windows, and Linux/BSD
# under X11; the Wayland backend is `smithay-clipboard` below (decision 0702).
# The EditBox clipboard chords, read on the main thread as macOS NSPasteboard requires; Wayland
# goes through `smithay-clipboard`. The 3.x bevy_egui uses, so the graph has one copy.
arboard = "3"
benilla-ui = { path = "../benilla-ui" }
bevy.workspace = true
wgpu.workspace = true
# Optional: the dev overlays are its only consumers (the panel, the perf pill, the inspector
# card), so a player build does not link it at all (`dev` feature, decision 1173).
bevy_egui = { version = "0.39", optional = true }
# Zero-copy POD→bytes for the shared light storage buffer (one per-frame write_buffer). Already in
# the tree transitively; pinned to the same version as the dev-dep below.
bytemuck = { version = "1", features = ["derive"] }
# `f16` decode for the booth-target dump (`WOW_BOOTH_DUMP`): the booth render targets are
# `Rgba16Float` (decision 0804) and the PNG wants display bytes. Already in the tree via
# wgpu/bevy_image; declared so the call site is an honest dependency.
# `f16` decode for the `WOW_BOOTH_DUMP` dump of the `Rgba16Float` booth render targets.
half = "2"
# The shipped FrameXML tree, compiled into the binary (decision 1175 §2): 61 files, ~2.8 MB, so the
# client cannot boot without its interface on a machine that has never seen our source. A proc
# 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.
# Compiles `assets/ui` into the binary, so the interface loads on a machine without the source.
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.
# The 1.12 chat filters (`text_filter`): the DBCs' 2289 profanity and 28 spam patterns, with one
# `RegexSet` for the any-match pass over every chat line.
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
# is about ("250 % CPU at 59 fps"). Was dev-only; since 1857 the output backend needs it in every
# build too (`mach_absolute_time`, the host clock its meters read; `thread_policy_set`, the render
# thread's realtime policy).
# `getrusage` for the perf probes' process CPU time; `mach_absolute_time` and `thread_policy_set`
# for the audio output's meter clock and its render thread's realtime policy.
libc = "0.2"
# Resample varying-size layer BLPs to a uniform size for the terrain texture array; `png` so we can
# encode the cursor image into data an NSImage can load (macOS hardware cursor).
# PNG encoding only: the macOS hardware cursor's NSImage data and screenshots.
image = { version = "0.25", default-features = false, features = ["png"] }
# The net↔ECS bridge: a background IO thread streams typed `SessionEvent`s to the ECS over a
# channel, and outbound `ClientCommand`s back. crossbeam's Sender/Receiver are `Send + Sync + Clone`
# (std's mpsc Sender is `!Sync`, so it can't be a Bevy resource without a mutex — the very thing this
# bridge deletes). The canonical Bevy background-thread→channel→system idiom. See decision 0006.
# Thread-to-ECS channels such as the net bridge's: crossbeam's `Receiver` is `Sync`, so either end
# can be a Bevy resource without a mutex.
crossbeam-channel = "0.5"
benilla-assets = { path = "../benilla-assets", default-features = false }
benilla-formats = { path = "../benilla-formats", default-features = false }
benilla-protocol = { path = "../benilla-protocol" }
benilla-world = { path = "../benilla-world", default-features = false }
# The CVar config diff (`benilla/config.toml`, decision 0954): serde derive on the persisted
# shape, toml as its format. (serde_json left with the era manifest — 0978.)
# The CVar diff persisted in `benilla-config/config.toml`.
serde = { version = "1", features = ["derive"] }
toml = "0.8"
# ECS-native physics: collider storage + broadphase BVH + shape-casts, with a thin custom kinematic
# character controller on top (decision 0009). Pinned 0.6: it targets Bevy 0.18 (verified single,
# unified bevy 0.18.1 in the tree); 0.7.0 jumps to Bevy 0.19 and would duplicate bevy. avian wraps
# parry under the hood, so adopting it lets us drop the *direct* parry3d dep once the bespoke
# CollisionWorld is gone. See docs… (decision 0007 superseded by 0009).
# Collision for our kinematic character controller; 0.6 targets Bevy 0.18, 0.7 moves to 0.19.
avian3d = "0.6"
# The glyph shaper + rasterizer for `FontString` text (decision 0068 §2, probe-verified in
# `probes/text-glyph`). Pinned to 0.16 — the exact version already in the tree transitively
# (`bevy_text`'s own dependency, see `Cargo.lock`) — so this is the *same* `cosmic-text` the decision
# already commits to, not a second copy. Used as a standalone shaper/rasterizer (`FontSystem` +
# `Buffer` + `SwashCache`) in `ui_text.rs`, never `bevy_text`'s `Text2d`/ECS integration (that path
# doesn't feed our custom z-ordered `UiQuad` stream).
# Shapes and rasterizes `FontString` text standalone (`ui_text`), at bevy_text's own 0.16.
cosmic-text = "0.16"
# The font database `cosmic-text` 0.16 uses (its `Source`/`ID`/`FaceInfo` appear in `ui_text.rs`'s
# public-in-crate types). Pinned to the exact version `cosmic-text` 0.16 depends on so there's one
# `fontdb` in the graph.
# `cosmic-text`'s font database, whose types `ui_text` names; must stay the version it depends on.
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 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.
# The mixer. Its `cpal` backend stays off on every target: `sound::output` is our own backend.
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
# realtime-safety regression fails a test or a smoke instead of reaching an ear. Release builds
# compile it out (`disable_release`, the crate's default) and carry no allocator wrapper.
# Aborts a debug build on an allocation in the audio IO callback or render pass; off in release.
assert_no_alloc = "1.1"
# kira's own decoder, named directly for one trait: the mixer feeds streams through its own
# `MediaSource` so kira's per-stream decode thread runs OUR code and can promote its QoS (decision
# 1109 — a bare-spawned decode thread starves under the world-entry burst and zero-fills the mix).
# No codecs of its own (kira's features enable those); must stay the version kira resolves — one
# symphonia in the graph, or the trait impl won't line up.
# For `MediaSource`: kira's decode thread reads through our source, which raises the thread's QoS
# so the world-entry burst cannot starve it. Must stay kira's version, or the trait won't match.
symphonia = { version = "0.5", default-features = false }
# The mix tap's audio-thread → writer-thread ring (decision 1112): the tap effect must never
# block or allocate on the render path. kira's own SPSC ring crate; kept on the version kira
# resolves so the graph carries one rtrb.
# The audio threads' lock-free SPSC rings (output, mix tap); kira's version, so the graph has one.
rtrb = "0.3"
mint = "0.5"
# The gossip greeting is a weighted random DRAW per menu-open, not a fixed pick (wow-re
# `gossip-npctext-law.md` — the reference re-rolls in its gossip handler), so the app needs a uniform
# source. Pinned to 0.8: the version `benilla-srp` already depends on, so this adds no crate.
# The gossip greeting's weighted draw, re-rolled per open as in the reference; `benilla-srp`'s 0.8.
rand = "0.8"
[dev-dependencies]
# The WMO portal-cull audit's raw-face instruments (`WOW_PIN_COL`): the pin probe compares the
# filtered walking-collision set against the group file's FULL face list with per-face MOPY flags,
# which only the raw parser exposes — benilla-formats' gathers are exactly the filtered views.
benilla-wmo = { path = "../benilla-wmo" }
# Linux/BSD: the Wayland clipboard (decision 0702). We build with bevy's `wayland` feature, so on a
# Wayland session winit gives us a native Wayland window and `arboard` — X11-only without its
# non-default `wayland-data-control` feature — finds no X11 CLIPBOARD owner and every paste fails.
# smithay-clipboard is the right crate for a windowed app: it drives the *standard* `wl_data_device`
# off our own `wl_display`/seat, which every compositor supports. arboard's own Wayland feature would
# not do: it speaks `wlr-data-control`, a privileged manager protocol GNOME/Mutter deliberately
# doesn't implement. `raw-window-handle` (pinned to the 0.6 already in the tree via bevy_window, so
# this adds no crate) is only needed to read the `wl_display` out of bevy's `RawHandleWrapper`.
# Linux/BSD Wayland clipboard: smithay-clipboard drives the standard `wl_data_device` on our own
# `wl_display`, read through raw-window-handle (bevy's 0.6). arboard's Wayland feature speaks
# `wlr-data-control`, which GNOME does not implement.
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))'.dependencies]
raw-window-handle = "0.6"
smithay-clipboard = "0.7"
# macOS: drive a native NSCursor for a crisp hardware cursor. winit drives the cursor via AppKit
# cursor-rects, which our continuous Metal render loop doesn't honor on mouse-move (custom cursor /
# hide revert to the arrow); we bypass it with NSCursor + NSWindow::disableCursorRects. See
# docs/knowledge/camera-control.md.
# macOS: AppKit through objc2 drives a native NSCursor with the window's cursor rects disabled;
# winit's cursor rects revert to the arrow under our continuous render loop (`cursor.rs`).
[target.'cfg(target_os = "macos")'.dependencies]
# The audio device layer (decision 1857, `sound::output::coreaudio`): the HAL output unit through
# coreaudio-rs's `AudioUnit`, and the AudioObject property/listener API through the objc2 CoreAudio
# bindings — the two crates cpal itself stood on, now used directly. The versions are the ones the
# graph already carries; the feature sets are the ones cpal proved to compile against.
# The audio device layer (`sound::output::coreaudio`): the HAL output unit through coreaudio-rs,
# the AudioObject property API through objc2-core-audio, at the versions and features cpal uses.
coreaudio-rs = { version = "0.13", default-features = false, features = ["audio_unit", "core_audio"] }
objc2-core-audio = { version = "0.3", default-features = false, features = ["std", "AudioHardware"] }
objc2-core-audio-types = { version = "0.3", default-features = false, features = ["std", "CoreAudioBaseTypes"] }
@@ -178,20 +99,12 @@ objc2-app-kit = { version = "0.2", features = [
] }
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.
# Off macOS the device layer's last hop runs over cpal (`sound::output::cpal`). macOS goes direct:
# there cpal cannot report or rebuild on a lost default device and drops the IO-cycle timestamp.
# An unsupported target gets cpal's Null host, which reports no device.
[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.
# Windows CPU clocks for `perf::clock`: `GetProcessTimes`, `GetThreadTimes`, `GetSystemTimes`.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
@@ -1,65 +1,19 @@
<!--
OUR adapters over the reference's own `Interface\FrameXML\ContainerFrame.xml`/`.lua`.
**This client does not transcribe those files — it EXECUTES them, off the player's own patch
chain** (decision 1751; `ui_script::reference_ui` carries the rule, and `benilla.toc` carries
the position). The bag windows on screen ARE `ContainerFrame1..12`. What is in THIS file is
only benilla's, and only where a reference body reaches for something this client genuinely
does not have.
The rule, restated in one line so it can be applied without re-reading the module: the sourced
file's globals stand unmodified; ours, loading after, win where both define a name; and where a
reference body needs a benilla answer, it is WRAPPED here — never silently stubbed.
TWO THINGS THIS FILE USED TO DO AND NO LONGER DOES, both retired by 1751's swap:
· It declared `ContainerFrameItemButtonTemplate`, transcribed from ContainerFrame.xml, because
the Lua could be sourced and the XML could not. The XML is sourced now, so the reference's
own declaration is the live one and the copy is deleted — the whole point of the migration.
· It defined `updateContainerFrameAnchors()` as a deliberate no-op, because the reference's
body walks twelve `ContainerFrame` windows this client did not build. It builds them, so the
reference's own body is correct here and the stub is deleted. Five corpus addons (Bongos,
Bongos_ActionBar, the CT_BarMod family) hook that global and call through; they now reach the
real thing.
── The adapters, and why neither is a stub ──────────────────────────────────────────────────
`ContainerFrameItemButton_OnEnter` opens with a keyring fork (ref l.615-620):
if this:GetParent():GetID() == KEYRING_CONTAINER then
GameTooltip:SetInventoryItem("player", KeyRingButtonIDToInvSlotID(this:GetID()))
CursorUpdate()
return
end
That fork exists because the REAL client's keyring items are inventory slots, not container
slots, so it has to detour out of the container API to describe one. benilla's keyring is an
ordinary container (id -2, decision 0765): its slots come through `GetContainerItemInfo` and
`GameTooltip:SetBagItem` like every other bag's — which is exactly why the reference's keyring
WINDOW, its verbs and its sounds all take our keyring unchanged, and why this one line is the
only thing in that file the swap did not simply inherit. The detour is not merely unavailable
here (`KeyRingButtonIDToInvSlotID` is not a binding here) — it is *wrong* here, and supplying
the verb would buy an empty tooltip, because our keyring items were never fed into the
inventory-slot space it reads.
So the wrapper below answers the keyring case the way benilla answers every other bag, and
hands EVERY other slot straight to the reference's own body. The path the player is actually
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 ────────────────────
· `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.
· `NO_EMPTY_KEYRING_SLOTS` is read by the reference's `PutKeyInKeyRing` when the ring is full,
and no such GlobalString exists — 1.12 ships only `NO_EMPTY_KEYRING_SLOTS_ERROR`. That is a
REFERENCE BUG, and this client used to un-quirk it. It does not any more: the reference's own
file runs, so a full keyring prints the reference's own empty error line. Faithful, and
deliberately so (1751: a divergence becomes faithful or becomes a record, never silent).
Our adapters over the stock `Interface\FrameXML\ContainerFrame.xml` and `.lua`, which run off
the player's patch chain: the bag windows are the stock `ContainerFrame1..12`. This file loads
after them, so its definitions win; a stock body that needs a benilla answer is wrapped here.
Deviation: benilla's keyring is a container (id -2) where the reference's is inventory slots,
so a keyring slot's tooltip comes from `GameTooltip:SetBagItem` like any bag's; the stock
keyring fork in `ContainerFrameItemButton_OnEnter` (ContainerFrame.lua l.615-620) reads the
inventory-slot space, which our keyring items are not in.
A full keyring prints the reference's empty error line: its `PutKeyInKeyRing` reads
`NO_EMPTY_KEYRING_SLOTS`, which 1.12 does not ship (it has `NO_EMPTY_KEYRING_SLOTS_ERROR`).
That is the reference's behaviour, kept.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The reference's own body, captured before we shadow the name. Nil on a machine with no
-- client data (nothing was sourced) — which must be LOUD at the call, not a quiet no-op:
-- a bag hover that silently does nothing is precisely the failure mode that let Bagnon
-- ship "working" through four green survey columns.
-- The stock body, captured before the name is shadowed. It is nil when no client data was
-- sourced, and the call then says so rather than doing nothing.
local refItemButtonOnEnter = ContainerFrameItemButton_OnEnter
function ContainerFrameItemButton_OnEnter(button)
@@ -68,9 +22,8 @@
end
if not button then return end
-- benilla's keyring is a CONTAINER (0765), so the reference's inventory-slot detour is
-- both unavailable and wrong here; this is the same pair of calls its general path
-- makes for every other bag, which is all a keyring slot ever needed.
-- Our keyring is a container, so its slot takes the stock general path's calls for a
-- bag slot instead of the inventory-slot detour.
if button:GetParent():GetID() == KEYRING_CONTAINER then
if (button:GetRight() or 0) >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
@@ -90,50 +43,30 @@
refItemButtonOnEnter(button)
end
-- ══ The three bag verbs benilla answers differently (decisions 0561 + 1751) ═════════
--
-- `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 `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.
--
-- 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.
-- ══ The three bag verbs benilla answers differently ═══════════════════════════════════
-- Deviation: `OpenBackpack` opens every equipped bag, not only the backpack, so the
-- vendor, bank, mail and trade windows that call it from OnShow bring up every bag to sell
-- or deposit from; `CloseBackpack` is its inverse, `CloseAllBags` the unconditional form.
-- The was-open memory is keyed by bag id because `ContainerFrame1..12` are recycled across
-- bags, and every open and close goes through the stock `OpenBag`/`CloseBag`/`IsBagOpen`,
-- so their guards and any addon that replaces them apply.
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.
-- The unconditional close, from the stock ShowUIPanel's centre branch: it ignores the
-- was-open memory and covers the bank bags, as the reference's CloseAllBags does
-- (l.702-707).
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.
-- A bag already open is remembered and left alone, so CloseBackpack leaves open a window
-- the player opened; the reference's `backpackWasOpen`, one entry per bag.
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.
-- OpenBag's own test, repeated so the memory agrees with it: the backpack always,
-- another bag only when its slot holds one.
if bagId == 0 or GetContainerNumSlots(bagId) > 0 then
if IsBagOpen(bagId) then
BENILLA_BAG_WAS_OPEN[bagId] = 1
@@ -153,9 +86,8 @@
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).
-- Published after the stock ContainerFrame.lua has loaded, so these are the definitions its
-- callers reach (the stock `ShowUIPanel`'s `CloseAllBags`, a vendor's `OpenBackpack`).
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
+51 -209
View File
@@ -1,106 +1,20 @@
<Ui>
<!--
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 (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):
· STRUCTURE & BEHAVIOR — the 1.15 era client (wow-ui-source-era @ 1.15.9.68940): the
ladder shape, its groups and gaps, which entries exist (the SCOPE section below).
If 1.15 has the mechanism, it STAYS.
· CHROME MECHANISM — this window predates 1.13: the 1.12 client's OWN GameMenuFrame
is the chrome ground truth (the dialog backdrop, header plate, red buttons — the
same dialog family the 1.13/1.14 options windows kept), quoted below.
· ART BYTES — the 1.12 MPQ chain only; nothing later-client at runtime (0978's law).
Ground truth, all quoted rather than re-derived:
· **The frame** — ref `GameMenuFrame.xml` (patch.MPQ, extracted): 195x246, `frameStrata`
DIALOG, the UI-DialogBox backdrop with the 11/12/12/11 insets and 32px tile/edge, the
256x64 UI-DialogBox-Header at TOP +12 with MAIN_MENU centered 14px down it, and the
eight-button ladder — first button CENTER on the frame's TOP at (0,-37), each next one
TOP-to-BOTTOM at (0,-1), and Return to Game hung (0,-16) below Exit Game (the one gap in
the stack, which is what makes the last entry read as separate).
· **The button** — ref `GameMenuButtonTemplate` (UIPanelTemplates.xml l.403-410): a 144x21
`UIPanelButtonTemplate` (the UI-Panel-Button Up/Down/Disabled/Highlight four-state art,
TexCoords 0..0.625 / 0..0.6875) whose label trio is overridden to
GameFontHighlight/GameFontHighlight/GameFontDisable — i.e. the menu's labels are WHITE,
not the gold a normal panel button wears, and grey when disabled.
· **The labels** — GlobalStrings.lua (patch-2.MPQ), verbatim: VIDEOOPTIONS_MENU "Video
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 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 (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
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):
- **The ladder is the ERA menu's** (director's call on the 0951 review — supersedes 0951
§3's five-button collapse): the Era client's own ESC menu shape, greyed where benilla
has nothing behind the entry yet. Geometry quoted from the era dump @ 1.15.9 (Gethe
wow-ui-source, classic_era, the 0950 pin):
· Blizzard_SharedXML/Classic/Frame/MainMenuFrameTemplates.xml l.18-33 — the menu is a
VerticalLayoutFrame, buttonTemplate MainMenuFrameButtonTemplate, padding
top/bottom/left/right = 32/28/28/28, spacing 0; l.11-16 — the button is a 144x21
UIPanelButtonTemplate with the white/white/grey label trio (the SAME art + trio the
1.12 GameMenuButtonTemplate carries — no era-only art in this menu at all).
· Shared/Frame/MainMenuFrameTemplates.lua l.61-71 — AddSection() puts a 20-unit gap
(the next button's topPadding) between groups; AddCloseButton uses the same 20.
· Shared/GameMenuFrame.lua l.62-162 — the Era ladder: Options · [gap] · AddOns, Edit
Mode, Support, Macros · [gap] · Logout, Exit Game · [gap] · Return to Game. (Store /
Rewards / What's New / Ratings are structurally off in Era — store disabled, no
account store, IsStandard() false, koKR only.)
· The layout math those constants produce, precomputed here (this engine has no layout
frames) — for OUR seven rungs, the era's eight minus AddOns (next bullet): width
28+144+28 = 200; button tops 32/73/94/115/156/177/218; height 218+21+28 = 267.
Anchored as a TOPLEFT chain so the gaps read in the XML.
· No Key Bindings rung — the era menu's own shape: 1.15 ceded the entry to the
Settings panel, and since decision 1008 ours lives there too (the Options window's
Keybindings category, KeyBindingsPage.xml). 0997 had carved the 1.13/1.14 seat back
in for its standalone window; that seat left with the window.
· No AddOns rung — the director's call: the char-select AddOns screen
(`char_select::addons`, the reference's own glue-side home for this list) is the only
addon UI. The in-game panel 1197 put behind this rung (AddonList.xml) is retired,
and the rung left with it — 1.12's own ESC menu has no AddOns row either.
· The header — Classic/Dialog/DialogTemplates.xml l.11-43 (ClassicDialogHeaderTemplate):
the same 256x64 UI-DialogBox-Header art, text = MAINMENU_BUTTON, swapped to
GameFontNormalMed1 (13px) by MainMenuFrameMixin:OnLoad. Both header idioms (1.12's
TOP+12 texture / era's 39-tall frame with the art centered) seat the art within half
a unit of each other; the 1.12 transcription is kept, the font and string are era's.
- **Edit Mode / Support ship DISABLED** — nothing behind them yet
(`GameMenuButton_Pending`): the template's own `-Disabled` art + grey label, exactly
how the era menu greys a dead entry (SetEnabled(false) → DisabledFont). Each goes live
by deleting one `GameMenuButton_Pending` call and writing its OnClick.
- The Options label stays a literal "Options": 1.12 GlobalStrings has no era
GAMEMENU_OPTIONS (checked; its OPTIONS_MENU is "Options Menu", a different string).
- The ref frame is `toplevel="true" movable="true" parent="UIParent"`. `parent="UIParent"` is
ours too, and it attaches for real: the loader does read a cross-file `parent=`, resolving
the name by global lookup at load (decision 1734), and the frame below declares it. Only
`movable` is dropped, and it is inert in the ref too (GameMenuFrame defines no drag
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`), 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.
The game menu, the frame ESC opens. Its chrome is the reference GameMenuFrame.xml's: DIALOG
strata, the UI-DialogBox backdrop (insets 11/12/12/11, 32px tile and edge), the 256x64
header at TOP +12 with the title 14px down it, the reference's click sounds, and the stock
`GameMenuButtonTemplate`, which the era's MainMenuFrameButtonTemplate matches in art,
size and label fonts.
Deviation: the ladder is the 1.15 era client's ESC menu (GameMenuFrame.lua l.62-162),
because its settings screens replace 1.12's, which are much worse to use. It keeps the
era's layout numbers, padding 32 top and 28 elsewhere (MainMenuFrameTemplates.xml
l.18-33) and a 20-unit gap between groups (MainMenuFrameTemplates.lua l.61-71), and drops
the era's AddOns rung, because the character-select AddOns screen is the only addon UI;
Key Bindings is in the options window, as in the era.
Names are the reference's, unprefixed: addons address `GameMenuFrame` by name.
-->
<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 retired MicroMenu.xml's idiom.
-- Label strings as `X = X or "..."`, so a stock GlobalStrings value wins where one exists.
MAIN_MENU = MAIN_MENU or "Main Menu"
HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode"
GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support"
@@ -110,50 +24,29 @@
RETURN_TO_GAME = RETURN_TO_GAME or "Return to Game"
CANCEL = CANCEL or "Cancel"
-- One label setter for a button with nothing behind it yet (Edit Mode and Support): set
-- the ref's text, then disable (grey label + the template's -Disabled art). Making it
-- live is deleting the call and writing its ref OnClick.
-- Labels and disables a rung with nothing behind it yet (Edit Mode, Support), greyed on the
-- template's -Disabled art the way the era menu greys a dead entry.
function GameMenuButton_Pending(button, text)
button:SetText(text)
button:Disable()
end
-- ══ 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".
-- ══ Our options window in the stock "is an options window open?" checks ══
-- The three stock functions name the reference's options windows, which here are hidden
-- stock frames, so each is wrapped to add `BenillaOptionsFrame` and still runs the stock
-- body. Wrapped here because this file loads last: `UpdateMicroButtons`
-- (MainMenuBarMicroButtons.xml) loads after 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.
-- Guarded for a test kit that loads the game menu without the options window.
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.
-- `UIParent.lua:996`, read by `CloseAllWindows_WithExceptions`: a yes keeps the ESC sweep
-- off the centre panel, which our window is, so ESC closes it through its own rung.
function IsOptionFrameOpen()
if BenillaOptions_IsOpen() then
return 1
@@ -161,20 +54,13 @@
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.)
-- `UIParent.lua:1465-1497`, the ESC ladder: static popup first, then an open options
-- window, then the rest of the stock ladder. The stock body re-runs the popup rung, a pure
-- scan when nothing is shown (`StaticPopup.lua:1879-1893`). Our Close button's click is
-- the discard-and-hide the reference reaches through `OptionsFrameCancel:Click()`.
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.
-- As the reference's `clicked` arm: close an options window, then still toggle.
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
end
@@ -190,17 +76,9 @@
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.
-- `MainMenuBarMicroButtons.lua:47-58`: a post-hook that only ever pushes the Main Menu
-- button, never releases it, so the stock decision over its six frames stands and ours adds
-- the seventh. Guarded for a test kit that loads this file without the micro bar.
function UpdateMicroButtons()
if Ref_UpdateMicroButtons then
Ref_UpdateMicroButtons()
@@ -210,35 +88,20 @@
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.
]]></Script>
<!-- The era header text size (Classic/Dialog/DialogTemplates.xml + the OnLoad font swap):
GameFontNormalMed1 = SystemFont_Med2 = FRIZQT 13, gold, the MasterFont shadow. Era's
name, declared here beside its one consumer (the 0951 residue convention: Fonts.xml
stays the complete 1.12 set). -->
<!-- The era's header font, GameFontNormalMed1 (FRIZQT 13, gold): an era name 1.12's Fonts.xml
lacks, declared beside its one consumer. -->
<Font name="GameFontNormalMed1" inherits="GameFontNormal" virtual="true">
<FontHeight><AbsValue val="13"/></FontHeight>
</Font>
<!-- `GameMenuButtonTemplate` MOVED OUT, to UIPanelTemplates.xml — the reference's own file for
it (ref UIPanelTemplates.xml l.403-410), and now ColorPickerFrame.xml's Okay/Cancel need it
too, from far above this file's LAST-in-the-toc seat. It lived here, with its four state
textures inlined, only because `UIPanelButtonTemplate` had not been transcribed yet; it now
has been, so the reference's actual body (`inherits="UIPanelButtonTemplate"`, 144x21, the
white/white/grey label trio) replaces the private copy verbatim. Era's
MainMenuFrameButtonTemplate (Classic/Frame/MainMenuFrameTemplates.xml l.11-16) is that same
button — same art, same trio, same size — so the header's ART BYTES note is unchanged. -->
<!-- `toplevel` is the ref's own (GameMenuFrame.xml l.3) and was dropped here: without the bit
a DIALOG-strata window opened over another one stays behind it (the B180/Bagnon class,
decision 1318). Restored alone; the ref's `movable`/`parent` stay off deliberately. -->
<!-- The reference frame's attributes (GameMenuFrame.xml l.3) except `movable`, which is inert
there: the reference defines no drag scripts. -->
<Frame name="GameMenuFrame" parent="UIParent" frameStrata="DIALOG" toplevel="true" enableMouse="true" hidden="true">
<!-- The era layout engine's own numbers (header SCOPE note): 28+144+28 wide, 32-top /
28-bottom padding around our seven 21-unit rungs with three 20-unit section gaps. -->
<!-- The era layout: 28+144+28 wide; 32 top and 28 bottom padding around seven 21-unit
rungs and three 20-unit section gaps. -->
<Size><AbsDimension x="200" y="267"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
@@ -252,9 +115,8 @@
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="12"/></Offset></Anchor></Anchors>
</Texture>
<!-- 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). -->
<!-- The era's 13px header font (MainMenuFrameMixin:OnLoad) on the 1.12 seat, which
is within half a unit of the era's. -->
<FontString name="GameMenuFrameTitle" inherits="GameFontNormalMed1" text="MAIN_MENU">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuFrameHeader"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor>
@@ -263,18 +125,10 @@
</Layer>
</Layers>
<Frames>
<!-- The era ladder minus its AddOns rung (header SCOPE notes): Options · gap ·
Edit Mode, Support, Macros · gap · Logout, Exit Game · gap · Return to Game.
A TOPLEFT chain at the era layout engine's numbers — x 28, first top -32, 0
between group members, -20 at each section break. Live entries wire the era
OnClick intent; pending ones grey out through GameMenuButton_Pending until
something exists behind them. -->
<!-- Options — live: the era GameMenuFrame.lua l.88 entry (ShowUIPanel(SettingsPanel)
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. -->
<!-- The era's Options entry (GameMenuFrame.lua l.88), opening our options window. A
literal label: 1.12 has no GAMEMENU_OPTIONS, and its OPTIONS_MENU is a different
string, "Options Menu". -->
<Button name="GameMenuButtonOptions" inherits="GameMenuButtonTemplate" text="Options">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="28" y="-32"/></Offset></Anchor>
@@ -304,8 +158,7 @@
<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(). -->
<!-- The reference's body (GameMenuFrame.xml l.118-122). -->
<Scripts>
<OnClick>
PlaySound("igMainMenuOption")
@@ -315,10 +168,8 @@
</Scripts>
</Button>
<!-- The three that are live. The ref's OnShow on both Logout and Quit disables the
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. -->
<!-- The reference's OnShow disables Logout and Exit Game while a CAMP or QUIT countdown
is up, so a running countdown cannot be re-armed. -->
<Button name="GameMenuButtonLogout" inherits="GameMenuButtonTemplate" text="LOGOUT">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonMacros" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
@@ -369,23 +220,14 @@
</Scripts>
</Button>
</Frames>
<!-- 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, in
the `CloseMenus and CloseMenus()` posture our retired panel file used: a headless test
loads this file without the action bar behind it. -->
<!-- The reference's OnShow/OnHide: the Main Menu micro button pushes in and the bag
buttons grey out while the menu is up. Guarded for a test that loads this file
without the action bar. -->
<Scripts>
<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 +
-- MainMenuFrameTemplates, grepped clean), it draws at UIParent's px-per-unit —
-- the SAME density as the era options window, whose checkFit caps at 1. The knob
-- is our stand-in for that UIParent scale; without it the menu drew 28% larger
-- relative to the options window than the era pair ("esc menu scale too big" —
-- director, 2026-08-04). `or 1` guards a load-order regression only. NOTE: this
-- lives INSIDE the existing OnShow — a second Scripts/OnShow block on the same
-- frame would silently REPLACE this one (SetScript semantics: last wins).
-- Deviation: ERA_WINDOW_SCALE (OptionsFrame.xml, loaded earlier) stands in for the
-- UIParent scale the era draws this menu and its options window at.
-- A second OnShow on this frame would replace this one: the last SetScript wins.
this:SetScale(ERA_WINDOW_SCALE or 1)
if UpdateMicroButtons then UpdateMicroButtons() end
if Disable_BagButtons then Disable_BagButtons() end
+72 -148
View File
@@ -1,77 +1,24 @@
<Ui>
<!--
The KEY BINDINGS page (decision 1008, superseding 0997's standalone window) — the
Options window's Keybindings category, over the engine's binding table
(benilla_ui::script::keybind + crate::bindings — GetNumBindings/GetBinding/SetBinding/
GetBindingKey/GetBindingAction/LoadBindings/SaveBindings/GetCurrentBindingSet, the 1.12
Lua API verbatim). This file is the page MODULE: the templates and the whole script; the
page's static furniture (the rows, the checkbox row, the scroll seam, the search rows)
lives in OptionsFrame.xml as ordinary children of its Keybindings body, inheriting these
templates (manifest order puts this file first). Rows are the HONEST TREE: only commands
the engine really dispatches are registered, so only they (and only their non-empty
categories) appear.
PROVENANCE (0985 — the three-source split; OptionsFrame.xml carries the sibling block):
· STRUCTURE & BEHAVIOR — the era Settings panel's own Keybindings page (wow-ui-source-era
@ 1.15.9.68940: Blizzard_SettingsDefinitions_Frame/Keybindings.lua + Classic/
KeybindingsOverrides.lua + Blizzard_Settings_Shared/Blizzard_Keybindings.*): 1.15 put
keybindings INSIDE the Options window — the director's call this move executes — as
ONE list of EXPANDABLE per-category sections (SettingsKeybindingSectionMixin over
SettingsExpandableSectionMixin; 25-tall rows, sections COLLAPSED by default, categories
in first-appearance order, a bar's section existing only when the bar does — their
PROXY_SHOW_ACTIONBAR_N gate is our honest tree), the Character Specific Keybindings
checkbox as the page's first row (PROXY_CHARACTER_SPECIFIC_BINDINGS, confirm popup on
UNCHECK only), and per-binding search tags whose category REDIRECTS to this page
(Blizzard_SettingsPanel.lua l.578 `category.redirectCategory or category`) with the
matched rows LIVE in the results.
· CAPTURE & SET LAW — 1.12's own Blizzard_BindingUI (extracted from patch.MPQ, 0997),
KEPT over the era's listener where they differ (the game's fidelity truth is 1.12;
the window furniture is the era's): click a key capsule to arm; the next key or
mouse-3/4/5 press binds IMMEDIATELY (the host capture seam hands the canonical
ALT-CTRL-SHIFT chord to KeyBindings_OnHostKey); a stolen key names its victim in red
(KEY_UNBOUND_ERROR) only when the victim goes bare; left/right clicks select/deselect,
never bind; lone modifiers are ignored; the wheel binds like any other key, to ANY
command (decision 1295 — SetBinding refuses nothing but a key string that is not a key,
and the KEYBINDINGFRAME_MOUSEWHEEL_ERROR arm below is that refusal's message, not a
wheel rule); ESC while armed BINDS Escape (1.12's OnKeyDown has no ESCAPE filter — the
era's ESC-cancels law is the one era behavior NOT taken, recorded in 1008; the Unbind
Key button is the clearing affordance). ESC with nothing armed never reaches any of
this: the ladder is the TOGGLEGAMEMENU binding, and the armed seam swallows the press.
· ART BYTES — the 1.12 MPQ chain only (0978/0985): the capsules wear 1.12's own
UIPanelButtonTemplate2 panel-button art (the Classic client's silver stretch set —
UI-Silver-Button-Up/-Down/-Highlight/-Select, a 9-slice — is VERIFIED ABSENT from
the 1.12 chain: 0997, re-pinned and reverted-to-red in 1088 after 1085's gray-dress
detour), selection is 1.12's LockHighlight glow; section headers wear the 1.12
collapsible-list dress — the QuestLog's own UI-PlusButton/UI-MinusButton square
and gold label.
COMMIT LAW (1008, the page's one big behavior change from 0997): binds are LIVE-COMMIT.
The options window's whole grammar writes live (SetCVar on click, no Okay/Cancel pair),
and the era Settings panel commits keybinds as they land too — so every successful
bind/steal/unbind/reset saves the current set to disk at once
(SaveBindings(GetCurrentBindingSet())); 0997's Okay/Cancel/revert and the
CONFIRM_LOSE_BINDING_CHANGES popup die with the standalone window. The two destructive
gestures keep their confirms: Reset To Default (the page Defaults button) and leaving
the character-specific set (which permanently deletes it).
All strings are the 1.12 GlobalStrings globals (the VM executes the real file at boot)
through the `getglobal(...) or raw` fallback (the era's own `_G[x] or x` posture), so a
headless test without GlobalStrings still renders raw tokens.
SCOPE / divergences (noted, not bugs — the ledger is 1008):
- No Quick Keybind Mode row (era furniture over an overlay mechanism benilla lacks).
- 1.12's BLANK spacer-headers between multibar blocks are not modeled — both bottom
bars sit under the one MultiActionBar section.
- 1.12's "Key Bindings for <name>" title swap died with the standalone window: the page
title stays the category's, the checkbox is the character-set state (era's own shape).
- Search shows at most KEYBINDINGS_SEARCH_ROWS matched bindings (static row pool; the
era's results list scrolls, ours doesn't yet) — the group head jumps to the full page.
The key bindings page: the options window's Keybindings category, over the engine's
binding table and the 1.12 binding API. This file holds the templates and the script; the
rows are children of the Keybindings body in OptionsFrame.xml, which loads after it. Only
commands the engine dispatches are registered, so only they and their categories appear.
Deviation: the page is the era Settings panel's Keybindings page (Keybindings.lua,
KeybindingsOverrides.lua), because 1.12's settings screens are much worse to use. It is
one list of collapsible category sections, the Character Specific Keybindings checkbox as
its first row, and search results that open this page.
Capture and set follow 1.12's Blizzard_BindingUI where the two differ: a capsule click
arms, the next key or mouse 3/4/5 press binds at once, left and right clicks only select,
the wheel binds like any key, and ESC while armed binds Escape (1.12's OnKeyDown has no
ESCAPE filter; Unbind Key clears).
Deviation: every bind, unbind and reset saves at once, as the era panel commits, where
1.12 had Okay and Cancel; only Reset To Default and leaving the character set confirm.
-->
<!-- One KEY CAPSULE (1.12 KeyBindingFrameBindingButtonTemplate: UIPanelButtonTemplate2 at
180x22 with the small-highlight font trio; its RegisterForClicks takes all five
buttons so middle/4/5 presses reach the capture law). The SELECTED state is 1.12's
LockHighlight — the ADD highlight held on. -->
<!-- A key capsule, 1.12's KeyBindingFrameBindingButtonTemplate: UIPanelButtonTemplate2 art at
180x22 with the small font trio, registered for all five buttons so a middle, 4 or 5
press reaches the capture. Selected is its LockHighlight, the ADD highlight held on. -->
<Button name="KeyBindingButtonTemplate" virtual="true">
<Size><AbsDimension x="180" y="22"/></Size>
<ButtonText name="$parentText"/>
@@ -87,14 +34,10 @@
</Scripts>
</Button>
<!-- One LIST ROW, dual-mode (the era list mixes section headers and binding rows in one
25-tall rhythm — SettingsKeybindingSectionInitializer:GetExtent's single bindingHeight).
BIND mode: command name 170 wide at LEFT, the two capsules at 175/355 (0997's seats,
kept). HEADER mode: the whole row is one toggle button wearing 1.12's own collapsible
dress — the QuestLogTitleButton's 16x16 UI-Plus/MinusButton square at LEFT and the gold
label (the era's Options_ListExpand arrows have no 1.12 counterpart). The paint
(KeyBindings_PaintBindRow / _PaintHeaderRow) shows one mode's furniture and hides the
other's. -->
<!-- A list row, painted as a binding (name, two capsules) or as a section header: one toggle
in 1.12's QuestLogTitleButton dress, the 16x16 plus/minus square and gold label, since
the era's expand arrows are not in 1.12. Both are 25 tall, the era's one row height
(SettingsKeybindingSectionInitializer:GetExtent). -->
<Frame name="KeyBindingRowTemplate" virtual="true">
<Size><AbsDimension x="560" y="25"/></Size>
<Layers>
@@ -141,19 +84,17 @@
</Frame>
<Script><![CDATA[
-- ── Strings ── the 1.12 GlobalStrings globals, each with the raw fallback (a headless
-- harness without GlobalStrings still works; the era's own `_G[x] or x` posture).
-- ── Strings ── a 1.12 GlobalStrings global, or the fallback for a harness without them.
function KeyBindings_String(name, fallback)
local s = getglobal(name)
if s then return s end
return fallback
end
-- ── 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 the stock GetBindingText (UIParent.lua);
-- this page and the tooltips show the full form, both references' own split.)
-- ── Key display ── 1.12's GetBindingText in full form, as the reference shows it here and
-- in tooltips (the action bar's abbreviated form is the stock GetBindingText): modifier
-- prefixes stay uppercase tokens, the base key reads its KEY_* global, else itself.
-- "CTRL--" is CTRL plus the minus key.
function KeyBindings_KeyText(key)
if not key then
return NORMAL_FONT_COLOR_CODE .. KeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
@@ -178,10 +119,9 @@
return prefix .. KeyBindings_String("KEY_" .. rest, rest)
end
-- ── Page state ── no root frame anymore: the page's state lives here. `sections` is the
-- era section model; `selected`/`keyID` the 1.12 selection; `searchPainted` the live
-- search-result rows (rowName → binding index), set by OptionsFrame.xml's search display
-- and repainted after a bind lands on one.
-- ── Page state ── `sections` is the era section model, `selected`/`keyID` the 1.12
-- selection, and `searchPainted` maps OptionsFrame.xml's live search rows (row name to
-- binding index).
KeyBindingsPage = { sections = {}, items = {} }
KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
@@ -189,10 +129,9 @@
KEYBINDINGS_ROW_HEIGHT = 25
KEYBINDINGS_SEARCH_ROWS = 8
-- ── The section model (era CreateKeybindingInitializers, cut to the honest tree): walk
-- GetBinding(i), group runs of one category token; order = first appearance, which IS
-- 1.12's Bindings.xml order (the registry registers in it). Sections default COLLAPSED
-- (the era Classic overrides pass no `expanded`); a rebuild keeps each token's state.
-- ── Section model ── the era's CreateKeybindingInitializers: one section per category
-- token in first-appearance order, which is 1.12's Bindings.xml order. Sections start
-- collapsed (the era's Classic overrides pass no `expanded`); a rebuild keeps each state.
function KeyBindings_BuildSections()
local page = KeyBindingsPage
local was = {}
@@ -214,8 +153,8 @@
KeyBindings_Flatten()
end
-- The flattened visible list (era EvaluateVisibility): every header, then the expanded
-- sections' rows. The faux scroll runs over this.
-- The visible list the faux scroll runs over (the era's EvaluateVisibility): each header,
-- then its rows when expanded.
function KeyBindings_Flatten()
local page = KeyBindingsPage
page.items = {}
@@ -229,8 +168,7 @@
end
end
-- The expand/collapse click (era OnExpandedChanged; the sound is the checkbox kit's,
-- the page's one click voice).
-- A header click (the era's OnExpandedChanged), voiced with the checkbox sound.
function KeyBindings_ToggleSection(n)
local sec = KeyBindingsPage.sections[n]
if not sec then return end
@@ -240,8 +178,7 @@
KeyBindingsPage_Update()
end
-- Expand/collapse by section index without the click voice — the capture fixture uses
-- it to open a section for the lens.
-- Expands or collapses a section silently; the capture fixture opens sections with it.
function KeyBindings_ExpandSection(n, on)
local sec = KeyBindingsPage.sections[n]
if not sec then return end
@@ -249,9 +186,8 @@
KeyBindings_Flatten()
end
-- ── Painters ── one row, one mode. Shared by the list repaint and the search rows
-- (which are the SAME template painted bind-mode — the era reflows live rows into its
-- results, and these capsules run the full capture law in place).
-- ── Painters ── one row in one mode, shared by the list and the search rows, whose
-- capsules capture in place as the era's live results do.
function KeyBindings_PaintBindRow(name, index)
local command, _, key1, key2 = GetBinding(index)
local b1 = getglobal(name .. "Key1Button")
@@ -265,7 +201,6 @@
local desc = getglobal(name .. "Description")
desc:SetText(KeyBindings_String("BINDING_NAME_" .. command, command))
desc:Show()
-- The 1.12 selection highlight: the pressed capsule holds its glow.
local page = KeyBindingsPage
if page.selected == command and page.keyID == 1 then
b1:LockHighlight()
@@ -320,16 +255,14 @@
end
FauxScrollFrame_Update(scroll, table.getn(page.items),
KEYBINDINGS_DISPLAYED, KEYBINDINGS_ROW_HEIGHT)
-- The trough is OURS — the reference has no trough at all, so nothing in its kit shows
-- one. The owner pairs it with the bar the reference's `_Update` just decided about
-- (1860); `BenillaScrollBar_SetShown` used to do it and is off this path now.
-- The trough is ours and the stock kit never shows one, so it follows the bar
-- `FauxScrollFrame_Update` has just shown or hidden.
BenillaScrollTrough_Follow(scroll:GetName())
KeyBindings_UpdateUnbindKey()
end
-- 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.
-- The page entry (BenillaOptionsFrame_SelectCategory): rebuilds the sections, since a
-- rebind or a set switch may have changed the table, then repaints from the top.
function KeyBindingsPage_Refresh()
KeyBindings_BuildSections()
local box = getglobal("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck")
@@ -346,9 +279,9 @@
getglobal("BenillaOptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
end
-- ── Selection + the capture arm ── selecting a capsule arms the HOST's capture seam
-- (BenillaBindCapture): raw input is swallowed there and comes back canonical through
-- KeyBindings_OnHostKey. Deselecting (either way) disarms.
-- ── Selection ── selecting a capsule arms the host's capture (BenillaBindCapture), which
-- swallows raw input and hands the canonical chord to KeyBindings_OnHostKey; deselecting
-- disarms.
function KeyBindings_SetSelected(command, keyID)
KeyBindingsPage.selected = command
KeyBindingsPage.keyID = keyID
@@ -364,9 +297,8 @@
end
end
-- Repaint whichever view currently shows binding rows: the page list, or the live
-- search-result rows (a bind landed from one must relabel it — and its victim, which
-- may sit on another visible row).
-- Repaints whichever view shows binding rows, the list or every search row, since a bind
-- from one search row can unbind a victim shown on another.
function KeyBindings_RepaintVisible()
if KeyBindingsPage.searchPainted then
for name, index in pairs(KeyBindingsPage.searchPainted) do
@@ -379,10 +311,9 @@
end
function KeyBindingButton_OnClick(button)
-- The 1.12 law: while something is selected, only LEFT/RIGHT on a capsule
-- re-select or deselect — a middle/4/5 press is a BIND, and the host's armed
-- capture seam (which runs after this click) takes it, so this handler must not
-- touch the selection for those buttons.
-- As 1.12: while armed, only a left or right click reselects or deselects. A middle,
-- 4 or 5 press is a bind that the armed capture takes after this click, so it must
-- leave the selection alone.
local page = KeyBindingsPage
if page.selected then
if button ~= "LeftButton" and button ~= "RightButton" then
@@ -406,15 +337,14 @@
KeyBindings_RepaintVisible()
end
-- The live-commit half of every mutation (1008's commit law — the header block): the
-- table already moved; persist the set it belongs to.
-- The live commit: saves the set a bind, unbind or reset has just changed.
function KeyBindings_Commit()
SaveBindings(GetCurrentBindingSet())
end
-- The host's capture callback: `chord` is canonical (ALT-CTRL-SHIFT-TOKEN). The body is
-- 1.12's OnKeyDown selected-branch verbatim: steal message, clear the command's keys,
-- rebind in slot order so the pressed slot takes the new chord — then the live commit.
-- The host's capture callback, `chord` canonical (ALT-CTRL-SHIFT-TOKEN): 1.12's OnKeyDown
-- armed branch, then the live commit. A stolen chord names its victim in red only when it
-- was the victim's last key; the command's keys rebind in slot order.
function KeyBindings_OnHostKey(chord)
local page = KeyBindingsPage
if not page.selected then
@@ -449,11 +379,9 @@
KeyBindings_RepaintVisible()
end
-- 1.12 KeyBindingFrame_SetBinding, kept verbatim: a refused bind restores the slot's old
-- key and says why. The reference wrote this arm with the wheel in mind and named its
-- string for it, but the engine refuses only a key string that is not a key
-- (decision 1295), so this fires for a chord the capture seam could not name — never for
-- a wheel notch. The message stays 1.12's; ours is not the place to correct Blizzard.
-- 1.12's KeyBindingFrame_SetBinding: a refused bind restores the slot's old key. SetBinding
-- refuses only a string that is not a key, so this fires for a chord the capture could not
-- name, never for the wheel; the message stays 1.12's wheel text.
function KeyBindings_SetOrRestore(key, command, oldKey)
if not SetBinding(key, command) then
if oldKey then SetBinding(oldKey, command) end
@@ -463,8 +391,8 @@
end
end
-- The Unbind Key button (the Container header band, OptionsFrame.xml): 1.12's body —
-- the selected slot empties, the other slides into slot order — plus the live commit.
-- The Unbind Key button (OptionsFrame.xml): 1.12's body, which empties the selected slot
-- and slides the other into slot order, then the live commit.
function KeyBindings_Unbind()
local page = KeyBindingsPage
local key1, key2 = GetBindingKey(page.selected)
@@ -481,14 +409,12 @@
KeyBindings_RepaintVisible()
end
-- ── The character-specific set (1.12's three-set model under the era checkbox row) ──
-- CHECK: switch to the character set and save it at once — it starts as a copy of what
-- you had (LoadBindings(2) falls back to the account snapshot; SaveBindings(2) makes it
-- exist, which IS the checkbox's persisted truth). UNCHECK: destructive — the era
-- confirm-on-uncheck-only law; the box springs back until the popup decides. The accept
-- order is load-then-save: LoadBindings(1) puts the account set live FIRST, so
-- SaveBindings(1) persists it unchanged while dropping the character set (the confirmed
-- permanent delete — saving live-as-account here would smuggle the character binds in).
-- ── Character-specific set ── 1.12's binding sets under the era's checkbox. Checking
-- saves set 2 at once, a copy of the current binds (LoadBindings(2) falls back to the
-- account set), and that saved set is what the checkbox reads. Unchecking deletes it, so
-- it confirms (the era confirms only on uncheck) and the box stays checked meanwhile.
-- The accept loads set 1 before saving it; saving first would write the character binds
-- into the account set.
function KeyBindings_CharacterToggle(box)
PlaySound("igMainMenuOptionCheckBoxOn")
if box:GetChecked() then
@@ -516,9 +442,8 @@
KeyBindingsPage_Refresh()
end,
}
-- Reset To Default — the page's Defaults button (era: Defaults confirms; the string is
-- ours, 0997's note: the era's lives in its client binary, 1.12 has none). The reset
-- lands in the CURRENT set and commits live like every other mutation.
-- Reset To Default, the page's Defaults button: it confirms, as the era's does, with our
-- own string since 1.12 has none, and resets the current set with a live commit.
StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
button1 = KeyBindings_String("OKAY", "Okay"),
@@ -533,12 +458,11 @@
end,
}
-- ── Search (the era redirect law) ── every binding's search tag is its display name
-- uppercased (era AddSearchTags(GetBindingName(action))); matches score by the shared
-- law (OptionsSearch_Score) and surface under the Keybindings group head, whose click
-- jumps to this page (era Blizzard_SettingsPanel.lua l.578: the fake category's results
-- carry `redirectCategory`). The matched rows are LIVE (era reflows real initializers);
-- OptionsFrame.xml paints them onto its static KeybindSearch row pool.
-- ── Search ── a binding's tag is its display name uppercased (the era's
-- AddSearchTags(GetBindingName(action))); matches list under the Keybindings group head,
-- which opens this page (the era's `redirectCategory`, Blizzard_SettingsPanel.lua l.578).
-- OptionsFrame.xml paints them as live rows. Deviation: a static pool of
-- KEYBINDINGS_SEARCH_ROWS rows, where the era's results scroll.
function KeyBindings_SearchMatches(words)
local out = {}
for i = 1, GetNumBindings() do
File diff suppressed because it is too large Load Diff
+51 -120
View File
@@ -1,68 +1,31 @@
<Ui>
<!--
benilla's **script error log** (decision 1495) — the window B293 asked for, and it is
entirely OURS: 1.12 has no counterpart, so every name here wears the `Benilla` prefix (1254's
rule, `tests::reference_surface`'s gate).
## Why it exists beside `ScriptErrors`
`BasicControls.xml`'s red `ScriptErrors` dialog is the reference's whole answer to a Lua
fault, transcribed and installed by 1305 — and it stays exactly as it is. It shows **one**
message, because `_ERRORMESSAGE` guards on `ScriptErrors:IsVisible()`, and it remembers
nothing. Two reporters said so within days of it shipping: Goudy, *minutes after
screenshotting that dialog working*, *"some kind of lua error frame probably needs to be
implemented, as there are a lot of addons that still doesn't work"*; nazriel_0, *"probably we
should start using something like Bug Grabber to report those stack traces better"*.
So: the dialog is the interrupt, this is the log. Additive, no divergence — the reference
simply never had this instrument.
## Shape
`CraftFrame.xml`'s shape, which is the house idiom for "windowed panel, faux-scrolled row
list, detail pane that repaints on selection": 13 fixed 16px rows over a
`FauxScrollFrameTemplate`, and a **real** `<ScrollFrame>` detail pane below (the real kit,
not the faux one — a message is a `mlua` error string with its `stack traceback:` tail
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 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).
## Reading the model
Three engine reads, all from the retained log on the model
(`benilla_ui::script::diagnostics`): `BenillaGetNumScriptErrors()` →
`retained, totalEverRecorded`; `BenillaGetScriptErrorInfo(i)` → `seq, kind, message, count`
(1-based, oldest first, nil past the end); `BenillaClearScriptErrors()`. There is no per-frame
feed and nothing to keep in step — the window polls the log it is a view of.
Benilla's script error log. 1.12 has no such window, so every name here carries the
`Benilla` prefix (`tests::reference_surface` checks it). It sits beside the stock
`ScriptErrors` dialog, which shows one message and keeps none: the dialog interrupts,
this window keeps the log.
Shaped like the reference's CraftFrame: 13 fixed 16px rows over the stock faux-scroll kit
and a detail pane below. It registers in `UIPanelWindows`, which seats it in the left slot
and closes it on ESC, and stays out of `UISpecialFrames`, which belongs to addons.
The engine's log API: `BenillaGetNumScriptErrors()` returns retained and total-ever
counts; `BenillaGetScriptErrorInfo(i)` returns `seq, kind, message, count` (1-based,
oldest first, nil past the end); `BenillaClearScriptErrors()`. The window polls it.
-->
<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.
-- A left-slot window with a pushable above every stock panel's, so a game window opening
-- pushes the log aside.
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
-- index, and a row that wraps would break the fixed-height list the faux kit requires.
-- How much of a message a row shows; a wrapped row would break the faux kit's fixed rows.
BENILLA_SCRIPTLOG_ROW_CHARS = 52
-- Which row is selected (a `seq`, not an index — indices shift when the cap evicts, and a
-- selection that silently slides onto a different error is worse than one that goes away).
-- The selected row's `seq`, not its index: indices shift when the cap evicts.
BenillaScriptLogSelected = nil
-- 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.
-- A message's first line, trimmed to a row. `strsub` counts bytes and messages are UTF-8,
-- so the cut backs up over continuation bytes (0x80..0xBF) onto a character boundary.
function BenillaScriptLog_RowText(message)
local cut = strfind(message, "\n", 1, 1)
local head = message
@@ -79,13 +42,10 @@
return head
end
-- 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)
-- Coloured by how much of the addon is broken:
-- `load` red the addon is not running at all
-- `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.
-- `warn` grey a call was accepted and quietly did not do what it said
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
@@ -95,12 +55,8 @@
function BenillaScriptLog_Update()
local shown, total = BenillaGetNumScriptErrors()
-- 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".
-- `total` counts distinct failures ever recorded, `shown` what the cap kept. The
-- summary says "problems" because two of the three kinds are not errors.
local summary
if total == 0 then
summary = "No problems this session."
@@ -126,9 +82,7 @@
if seq then
button.seq = seq
button.message = message
-- The count rides the index, not the text: `#12 x1113` says "this is one
-- failure that happened 1,113 times", which is the whole reason the log
-- deduplicates instead of listing 1,113 rows.
-- `#12 x1113`: one deduplicated failure that happened 1,113 times.
local label = "#" .. seq
if count > 1 then label = label .. " x" .. count end
button.index:SetText(label)
@@ -143,9 +97,8 @@
end
end
-- The moving highlight, seated onto the selected row only when that row is on screen —
-- scrolling the selection away hides the glow rather than stranding it (CraftFrame's
-- own behaviour).
-- The highlight follows the selected row and hides when it scrolls away, as the
-- reference's CraftFrame does.
local highlight = getglobal("BenillaScriptLogHighlightFrame")
if selectedRow then
highlight:SetPoint("TOPLEFT", selectedRow:GetName(), "TOPLEFT", 0, 0)
@@ -157,8 +110,8 @@
BenillaScriptLog_UpdateDetail()
end
-- The detail pane: the FULL message of the selected row, traceback and all. Selection is by
-- `seq`, so this walks the log rather than indexing it.
-- The selected message in full, traceback and all; selection is a `seq`, so this walks
-- the log rather than indexing it.
function BenillaScriptLog_UpdateDetail()
local text = ""
if BenillaScriptLogSelected then
@@ -177,18 +130,14 @@
getglobal("BenillaScriptLogDetailText"):SetText(text)
end
-- Frame-late by nature (the shared kit's own note): text rects resolve after Lua runs, so
-- the child is re-measured from OnUpdate until it stops changing, after which this is a
-- no-op compare per frame. OnUpdate only fires on visible frames, so a closed window costs
-- nothing.
-- Text rects resolve after Lua runs, so the detail child is re-measured every frame; once
-- it settles that is one compare, and OnUpdate fires only while the window is shown.
function BenillaScriptLogFrame_OnUpdate()
BenillaScroll_ResizeChild("BenillaScriptLogDetailScroll", { "BenillaScriptLogDetailText" })
end
function BenillaScriptLogRow_OnClick(button)
if not button.seq then return end
-- Clicking the selected row deselects: the detail pane is a drawer, and a list with no
-- way back to "nothing selected" is a small trap.
if BenillaScriptLogSelected == button.seq then
BenillaScriptLogSelected = nil
else
@@ -198,20 +147,17 @@
end
function BenillaScriptLogFrame_OnLoad(self)
-- 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.
-- Masks the square icon to the portrait ring.
SetPortraitToTexture("BenillaScriptLogFramePortrait",
"Interface\\Icons\\INV_Misc_Note_01")
end
-- The shared helper steps the faux frame's bar, whose OnValueChanged runs the kit's
-- offset handler and thence `updateFunc` — so the repaint is wired once, at OnLoad, and
-- not repeated here.
-- Stepping the bar repaints through the list frame's OnVerticalScroll, so no repaint here.
function BenillaScriptLogFrame_OnMouseWheel(delta)
BenillaFauxScrollFrame_OnMouseWheel(getglobal("BenillaScriptLogListScrollFrame"), delta)
end
-- ── The three entry points the client and the player reach this by ────────────────────
-- ── Entry points ──────────────────────────────────────────────────────────────────────
function BenillaScriptLog_Toggle()
local frame = getglobal("BenillaScriptLogFrame")
@@ -222,10 +168,8 @@
end
end
-- `/errors clear`. Forgets the rows, keeps the numbering (the engine's own rule — a number
-- must never name two different failures across a screenshot). Prints a confirmation
-- because a command whose whole effect is "a window you may not have open is now empty"
-- is otherwise indistinguishable from a typo.
-- `/errors clear`: forgets the rows but keeps the numbering, so a number never names two
-- failures. It confirms in chat because the window may be closed.
function BenillaScriptLog_Clear()
BenillaClearScriptErrors()
BenillaScriptLogSelected = nil
@@ -236,9 +180,8 @@
end
]]></Script>
<!-- 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). -->
<!-- One list row: the `#N xCount` index column, then the message head, in fixed columns as
1.12's lists are. -->
<Button name="BenillaScriptLogRowTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
@@ -268,18 +211,15 @@
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<Layer level="BACKGROUND">
<!-- The same square ITEM icon the mail window's ring holds, and it needs the same
portrait verb for the same reason (`SetPortraitToTexture` in OnLoad below):
`file=` alone draws it unmasked and its corners show through the ring's
transparent ones. -->
<!-- A square item icon: OnLoad masks it with `SetPortraitToTexture`, since `file=`
alone shows its corners through the ring. -->
<Texture name="BenillaScriptLogFramePortrait" file="Interface\Icons\INV_Misc_Note_01">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<!-- The four quadrant slabs. The ClassTrainer family, all four, because it is the one
UNIFORM set in the 1.12 art (CraftFrame's own note) and this window belongs to no
profession — borrowing a themed half would make it look like a window it isn't. -->
<!-- The ClassTrainer quadrants, the one uniform set in the 1.12 art; this window
belongs to no profession. -->
<Layer level="BORDER">
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopLeft"><Size><AbsDimension x="256" y="256"/></Size><Anchors><Anchor point="TOPLEFT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopRight"><Size><AbsDimension x="128" y="256"/></Size><Anchors><Anchor point="TOPRIGHT"/></Anchors></Texture>
@@ -288,22 +228,18 @@
<FontString name="BenillaScriptLogTitle" inherits="GameFontNormal" justifyH="CENTER" text="Script Errors">
<Anchors><Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</FontString>
<!-- The summary line, and the ONLY place the dropped-row count can be told. -->
<!-- The summary line, the only place the dropped-row count shows. -->
<FontString name="BenillaScriptLogSummary" inherits="GameFontHighlightSmall" justifyH="LEFT" text="">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="26" y="-76"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- 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>
</Button>
<!-- Declared BEFORE the rows so row text paints over the glow (the house z-tiebreak). -->
<!-- Declared before the rows so their text draws over the glow. -->
<Frame name="BenillaScriptLogHighlightFrame" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
@@ -335,16 +271,14 @@
<Size><AbsDimension x="293" y="208"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
<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). -->
<!-- The stock kit sets the offset only from here and has no `updateFunc`, so
the repaint is wired here too. -->
<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
the same wiring. -->
<!-- The detail pane, on the real-scroll kit: a message with its `stack traceback:` tail
has variable height. -->
<ScrollFrame name="BenillaScriptLogDetailScroll">
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-314"/></Offset></Anchor></Anchors>
@@ -367,8 +301,7 @@
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- 270 wide so the engine wraps the traceback rather than clipping
it; the pane pans over whatever height that comes to. -->
<!-- 270 wide so the traceback wraps rather than clips. -->
<FontString name="BenillaScriptLogDetailText" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP" text="">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="6" y="-6"/></Offset></Anchor></Anchors>
@@ -392,18 +325,16 @@
</Frames>
<Scripts>
<OnLoad>BenillaScriptLogFrame_OnLoad(self)</OnLoad>
<!-- The list wheel lives on the WINDOW: the engine bubbles a spin up the parent chain
(CraftFrame's own note). The detail pane has its own, which wins over its own rect. -->
<!-- The list's wheel handler is on the window, since a spin bubbles up the parent
chain; the detail pane's own handler wins over its rect. -->
<OnMouseWheel>BenillaScriptLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>BenillaScriptLog_Update()</OnShow>
<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. -->
<!-- Benilla's slash commands in the stock SlashCmdList, which ChatEdit_ParseText walks after
its built-ins; each hands its line to SubmitChatInput, where ui_chat::input parses it.
This file loads after the stock ChatFrame.xml, which declares the table. -->
<Script><![CDATA[
local BENILLA_COMMANDS = {
{ "CHATTEST", "/chattest" },
+72 -211
View File
@@ -1,54 +1,30 @@
<Ui>
<!--
benilla's reusable scroll kits — TWO of them, sharing one scrollbar, one pair of arrow
helpers and one optional TROUGH (`BenillaScrollTroughTemplate`, at the foot: the recessed
channel a bar rides in — 1.12 authors that art per window, so an owner declares one beside
its bar and the kit shows and hides the two together).
The **faux-scroll** kit (this header) re-binds a fixed set of rows to a moving data
offset; the **real-scroll** kit (second half of the script block) pans and CLIPS a
variable-height child through the engine's `<ScrollFrame>` (decision 0112) — the one a gossip
menu of wrapped options needs, since its content height isn't known until it is laid out.
Only their OnValueChanged differs, and an instance overrides the template's (the loader
SetScripts every `<Scripts>` block in order, so the instance's handler wins).
The faux kit (decisions 0247/0250/0251): a draggable scroll bar over
the `Slider` primitive (0250) plus the fixed-row "faux scroll" Lua layer — the reference's
`FauxScrollFrame`, which never really scrolls a child: it re-binds a fixed set of row buttons to
a moving data offset. First customer: the class trainer's skill-line list (TrainerFrame.xml).
The quest log's chunk-local faux-scroll upvalue is a later migration onto this.
DESIGN — collapsed from the real client's three-part decomposition (0250: FauxScrollFrame =
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 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
rounding, so the thumb (drawn at the raw value fraction) and the list (drawn at the rounded
offset) never visibly disagree. The frame carries `frame.offset` (the top visible row, 0-based)
and `frame.updateFunc` (the owner's list-repaint, set by the owner's OnLoad).
Benilla's scroll kits, sharing one `Slider` bar, its arrow helpers and an optional trough:
a faux-scroll kit that re-binds a fixed set of rows to a moving data offset, and a
real-scroll kit that pans and clips a `<ScrollFrame>` child whose height is known only after
layout. A real-scroll bar overrides the template's OnValueChanged on its instance, which
wins because the loader runs every `<Scripts>` block in order.
Faux model: the value is pixels, `[0, (numItems - numToDisplay) * itemHeight]` in steps of
`itemHeight`; the parent holds the 0-based top row in `frame.offset` and its repaint in
`frame.updateFunc`, set by the owner's OnLoad.
Deviation: the faux parent is a plain `<Frame>`, not the reference's ScrollFrame relay,
which adds nothing because the reference's `SetVerticalScroll` stores the bar's value as is.
`FauxScrollFrameTemplate` and the `FauxScrollFrame_*`, `ScrollFrame_*` and
`ScrollingEdit_*` API are the stock `UIPanelTemplates` on the player's chain, which loads
after this file, so its declarations win.
-->
<Script><![CDATA[
-- Step the bar one row (the slider's valueStep) — the sensible step for an item list (the
-- generic ref scrollbar steps half its height; a discrete row list wants one). Silent by
-- design: the ref plays UChatScrollButton only on the arrow *buttons'* OnClick, never on the
-- wheel (ScrollFrameTemplate_OnMouseWheel is soundless). Both the wheel and the arrows call
-- this, so the click lives on the arrow OnClick, keeping wheel-scroll silent.
-- Steps the bar by its valueStep. Deviation: one row on a faux list, where the reference
-- bar's arrows step half its height. Soundless: the reference plays UChatScrollButton on
-- an arrow click, never on the wheel, so the arrows' OnClick plays it.
function BenillaScrollBar_Step(scrollBar, dir)
local step = scrollBar:GetValueStep()
if step <= 0 then step = 1 end
scrollBar:SetValue(scrollBar:GetValue() + dir * step)
end
-- Grey the up arrow at the top and the down arrow at the bottom (ref
-- ScrollFrame_OnScrollRangeChanged's arrow handling), from the slider's own value/range.
-- Greys each arrow at its end of the range (reference: ScrollFrame_OnScrollRangeChanged).
function BenillaScrollBar_UpdateArrows(scrollBar)
local minV, maxV = scrollBar:GetMinMaxValues()
local val = scrollBar:GetValue()
@@ -58,29 +34,11 @@
if val >= maxV then down:Disable() else down:Enable() end
end
-- The scrollbar's OnValueChanged: derive the row offset from the (pixel) value and, only when
-- it actually moved, repaint the owner's list. The change-gate is load-bearing: the owner's
-- repaint calls _Update below, which SetValue()s the thumb back to offset*itemHeight — a value
-- that maps to the SAME offset, so this handler no-ops and the cycle stops after one hop (the
-- Slider's own SetValue change-gate stops the value side, decision 0250).
--
-- THE SECOND HALF IS FOR ADDONS, and it is the reference's own wiring. The ref's
-- `UIPanelScrollBarTemplate` routes its value through the ScrollFrame it rides
-- (`<OnValueChanged>this:GetParent():SetVerticalScroll(arg1)</OnValueChanged>`,
-- ref-UIPanelTemplates.xml l.156-158), and that frame's `<OnVerticalScroll>` is where an
-- addon puts `FauxScrollFrame_OnVerticalScroll(itemHeight, MyUpdate)`. Our OWN faux frames
-- are plain `<Frame>`s (the 0251 divergence in the header above), so they have no
-- SetVerticalScroll and this line never fires for them — but an addon's is declared
-- `<ScrollFrame ... inherits="FauxScrollFrameTemplate">`, and the instance's own tag wins
-- over the template's on inherit (framexml.rs `merge`), so it really is one. Duck-typed for
-- exactly that reason.
--
-- 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.
-- The faux bar's OnValueChanged: the top row is the value rounded to the nearest row, so
-- thumb and rows agree, and `updateFunc` runs only when that row changes, which also ends
-- the loop when a repaint re-seats the thumb.
-- A ScrollFrame parent also gets the value through SetVerticalScroll, as the reference's
-- `UIPanelScrollBarTemplate` routes it (UIPanelTemplates.xml l.156-158).
function BenillaFauxScrollBar_OnValueChanged(scrollBar, value)
local frame = scrollBar:GetParent()
local step = scrollBar:GetValueStep()
@@ -94,27 +52,10 @@
if frame.SetVerticalScroll then frame:SetVerticalScroll(value) end
end
-- Seat a trough ON ITS BAR. THE SOCKETS, and why this is not a free choice: the channel art
-- is not a plain tube — each end carries a 16-tall SOCKET that the arrow button drops into,
-- bounded by the frame's outer cap and an inner crossbar. Measured texel-exact off
-- UI-Character-ScrollBar (64x256):
-- top piece (left column) : rows 0-3 CAP · rows 4-19 SOCKET · rows 20-21 crossbar
-- bottom piece (right column): rows 85-86 crossbar · rows 87-102 SOCKET · rows 104-106 CAP
-- Both pieces draw 1:1 anchored to their own end of the trough, and the arrow buttons sit 16
-- outside the slider's ends — so a socket lands on its arrow only when the trough OVERHANGS
-- the bar by 21 above and 20 below (4 rows of cap at the top against 3+1 at the bottom; that
-- 1 is the whole asymmetry). Those are the ref's own numbers, reached from the other side:
-- ReputationFrame hangs its trough at the scroll frame's TOPRIGHT (-2,+5) / BOTTOMRIGHT
-- (-2,-4) against a bar seated (6,-16)/(6,+16) — 21, 20, and the 8-left hang
-- (ref-ReputationFrame.xml l.573-599 against ref-UIPanelTemplates.xml l.166-181). Miss it by
-- even 4 and the arrows ride OUT of their sockets onto the caps with a sliver of bare socket
-- showing beside them, which is exactly what B224 reported.
--
-- Seated HERE, off the bar, rather than restated in each owner's XML: it is one law, and an
-- owner made to re-derive it in offsets is an owner that can get it wrong — which is how
-- B224 happened. So a trough carries NO anchors of its own; declare it before the bar (so it
-- draws beneath) and name it `<barname>Trough`, and that is the whole contract. Lazy,
-- because the bar is declared after the trough and does not exist at the trough's OnLoad.
-- Seats a trough on its bar with the reference's ReputationFrame hang, 8 left, 21 above and
-- 20 below (ReputationFrame.xml l.573-599 against UIPanelTemplates.xml l.166-181), which
-- puts each art socket on its arrow button. Lazy, because the bar is declared after its
-- trough and does not exist at the trough's OnLoad.
function BenillaScrollTrough_Seat(trough, scrollBar)
if trough.seated then return end
trough.seated = true
@@ -123,19 +64,9 @@
trough:SetPoint("BOTTOMRIGHT", scrollBar, "BOTTOMRIGHT", 7, -20)
end
-- Show/hide a bar TOGETHER WITH ITS TROUGH — the recessed channel it rides in, if the
-- owner authored one beside it (a `<barname>Trough` frame on BenillaScrollTroughTemplate
-- below). Every path that shows or hides a bar goes through here, so a window that fits
-- shows neither and one that overflows shows both — and so a trough is seated on its bar
-- before it can ever be visible, whichever way the first call goes.
--
-- Why the trough is OPT-IN rather than part of the bar template: 1.12 authors trough art
-- PER WINDOW, never in the shared scroll kit — `UIPanelScrollBarTemplate` carries only the
-- two arrow buttons and the knob, and the ref's FrameXML then hangs loose trough textures
-- beside 29 individual bars, in two different skins (UI-Character-ScrollBar for the
-- dialog/paperdoll family, UI-ClassTrainer-ScrollBar for the trainer family). The era's own
-- MinimalScrollBar went the other way and made its Track part of the bar; this pair keeps
-- the era's COUPLING (track lives and dies with the bar) on 1.12's per-window SKIN.
-- Shows or hides a bar and its `<barname>Trough` together, seating the trough first; show
-- and hide a bar only through here. The trough is opt-in because 1.12 draws trough art per
-- window: `UIPanelScrollBarTemplate` carries only the arrows and the knob.
function BenillaScrollBar_SetShown(scrollBar, shown)
local trough = getglobal(scrollBar:GetName() .. "Trough")
if trough then BenillaScrollTrough_Seat(trough, scrollBar) end
@@ -148,17 +79,9 @@
end
end
-- **Our trough, following the REFERENCE's bar.** `BenillaScrollBar_SetShown` above is the
-- Benilla kit's own pairing and nothing in the reference's faux kit calls it — the
-- reference has no trough at all, which is exactly why this template stays ours (1837's
-- manifest note). Since the faux frames took the chain's `FauxScrollFrameTemplate` their
-- bar is `UIPanelScrollBarTemplate`, so an owner that wants its trough asks for it here,
-- right after its `FauxScrollFrame_Update`. Seating is idempotent and the visibility
-- simply mirrors the bar's, which the reference's `_Update` has already decided by
-- showing or hiding the scroll FRAME.
--
-- Decision 1860. Named after the bar rather than the frame because the trough is declared
-- `<barname>Trough` — one law, stated once, exactly as BenillaScrollTrough_Seat wants it.
-- The same for a stock `FauxScrollFrameTemplate` frame, whose bar is the reference's: seats
-- `<frame>ScrollBarTrough` and mirrors the frame's shown state. Call it after
-- `FauxScrollFrame_Update`, which shows or hides the frame.
function BenillaScrollTrough_Follow(scrollFrameName)
local sf = getglobal(scrollFrameName)
if not sf then return end
@@ -170,45 +93,30 @@
if sf:IsShown() then trough:Show() else trough:Hide() end
end
-- A wheel spin over the list → one row (WoW convention: +1 up, −1 down). Called from the
-- owner window's OnMouseWheel (the engine bubbles the spin up the parent chain, so the handler
-- lives on the window, not a child — TrainerFrame.xml's own note).
--
-- KEEPS the `Benilla` prefix on purpose: the reference has no faux-scroll wheel function to
-- take the name of. Its `ScrollFrameTemplate_OnMouseWheel` is the REAL-scroll one and moves
-- by half the bar's height; its faux owners each write a wheel body of their own inline.
-- One row per wheel notch (+1 up) on a faux list, called from the owner window's
-- OnMouseWheel since the spin bubbles up the parent chain. Benilla-named: the reference has
-- no faux wheel function (its `ScrollFrameTemplate_OnMouseWheel` moves half the bar).
function BenillaFauxScrollFrame_OnMouseWheel(frame, delta)
local scrollBar = getglobal(frame:GetName() .. "ScrollBar")
BenillaScrollBar_Step(scrollBar, -delta)
end
-- ══════════════════════════ The REAL-scroll kit ══════════════════════════════════════════
--
-- The other half of the file: a true `<ScrollFrame>` (engine mechanism, decision 0112) that
-- pans and CLIPS a variable-height child, as against the faux kit above (fixed rows re-bound
-- to a data offset). Windows whose content height is unknown until it is laid out — a gossip
-- menu of wrapped options, a quest's body text — need this one; a uniform list wants the faux.
--
-- SHAPE, from the reference `UIPanelScrollFrameTemplate` (Interface\FrameXML\
-- UIPanelTemplates.xml l.166-211, extracted via mpqx): the scrollbar is a SLIDER that rides
-- its ScrollFrame as a child, so `bar:GetParent()` IS the frame it scrolls — no name
-- derivation, and `$parentBar` gives SyncBar its handle. The ref's OnVerticalScroll drives
-- the bar's value and greys the two arrows at the ends; ours does the same through the
-- shared arrow helper above.
BENILLA_SCROLL_STEP = 20 -- px per arrow click / wheel notch
BENILLA_SCROLL_PAD = 20 -- breathing room under the last element in the child
-- ══════════════════════════ The real-scroll kit ══════════════════════════════════════════
-- Shaped on the reference `UIPanelScrollFrameTemplate` (UIPanelTemplates.xml l.166-211):
-- the bar is a `Slider` child, `$parentBar`, so `bar:GetParent()` is the frame it pans.
BENILLA_SCROLL_STEP = 20 -- px per arrow click or wheel notch
BENILLA_SCROLL_PAD = 20 -- px of room below the child's last element
-- The bar moved (drag, arrow, or SyncBar): pan the frame it rides. The loop this could form
-- (bar → scroll → OnVerticalScroll → SyncBar → bar) dead-ends in one hop — SyncBar writes the
-- value the bar already holds, and the Slider's SetValue change-gate (decision 0250) drops it.
-- Pans the frame the bar rides. The loop back through OnVerticalScroll and SyncBar ends in
-- one hop: SyncBar writes the value the bar already holds, which Slider:SetValue ignores.
function BenillaScrollBar_OnValueChanged(bar, value)
bar:GetParent():SetVerticalScroll(value)
BenillaScrollBar_UpdateArrows(bar)
end
-- Reconcile a ScrollFrame's bar to its live range (ref ScrollFrame_OnScrollRangeChanged):
-- the bar spans [0, range] in pixels, seats at the current scroll, and hides entirely when
-- the content fits. Wired to both OnScrollRangeChanged and OnVerticalScroll by the owner.
-- Fits the bar to the frame's live range, [0, range] px, and hides it when the content fits
-- (reference: ScrollFrame_OnScrollRangeChanged). Owners call it from OnScrollRangeChanged
-- and OnVerticalScroll.
function BenillaScroll_SyncBar(sf)
local bar = getglobal(sf:GetName() .. "Bar")
if not bar then return end
@@ -220,10 +128,8 @@
BenillaScrollBar_SetShown(bar, range > 0)
end
-- 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).
-- Pans by `delta` px, clamped here: SetVerticalScroll stores any value, as the reference's
-- does; the reference's ScrollFrameTemplate_OnMouseWheel clamps through its bar's range.
function BenillaScroll_Step(sf, delta)
local target = sf:GetVerticalScroll() + delta
local range = sf:GetVerticalScrollRange()
@@ -233,13 +139,12 @@
BenillaScroll_SyncBar(sf)
end
-- WoW wheel convention: +1 per notch up, -1 down — up lifts the content.
-- The wheel's delta is +1 per notch up, and up scrolls toward the top.
function BenillaScroll_OnMouseWheel(sf, delta)
BenillaScroll_Step(sf, -delta * BENILLA_SCROLL_STEP)
end
-- A widget's resolved bottom edge for the content sum — nil when it isn't contributing
-- (hidden frame / blank FontString / not yet resolved this frame).
-- A widget's resolved bottom, or nil if hidden, a blank FontString, or not laid out yet.
function BenillaScroll_WidgetBottom(w)
if not w then return nil end
if w.IsShown then
@@ -251,19 +156,13 @@
return w:GetBottom()
end
-- Size a scroll child to its MEASURED content — the resolved bottom of the lowest visible
-- widget, plus padding — instead of a fixed budget, and tell the frame (which fires
-- OnScrollRangeChanged → the owner's SyncBar). Frame-late by nature: rects resolve after Lua
-- runs, so owners re-run this from an OnUpdate until it stops changing. Returns true when it
-- actually resized (only a real change writes: SetHeight invalidates layout, and an
-- every-frame write would fight the resolver).
-- Sizes the scroll child to its lowest visible widget plus BENILLA_SCROLL_PAD and updates
-- the frame's range. Rects resolve after Lua runs, so owners re-run it from OnUpdate until
-- it returns false; it writes only on a real change, since SetHeight invalidates layout.
function BenillaScroll_ResizeChild(scrollName, widgets)
local sf = getglobal(scrollName)
-- ASK THE FRAME, don't guess a name. This used to be
-- `getglobal(scrollName .. "Child")`, which only ever worked because every caller wore
-- a private name in that exact shape — the reference's own convention is
-- `<X>ScrollFrame` / `<X>ScrollChildFrame`, not a suffix, so the derivation broke the
-- moment QuestFrame took the reference's names. A ScrollFrame knows its own child.
-- The frame's own child, not a derived name: the reference pairs `<X>ScrollFrame` with
-- `<X>ScrollChildFrame`.
local child = sf:GetScrollChild()
if not child then return false end
local top = child:GetTop()
@@ -284,11 +183,9 @@
]]></Script>
<!--
The scroll bar: a `Slider` (0250) 16px wide, its up/down step buttons pinned above/below the
track, and the knob thumb. The value is in pixels (see the header); the thumb draws at the
value fraction along the track (engine-side, decision 0250 §4), draggable (§5). The step-button
+ knob art is the stock 1.12 scrollbar kit, each texture cropped to its centred 16px (the
`0.25..0.75` TexCoords the ref's `UIPanelScrollBarButton` applies).
The bar: a 16px `Slider` with step buttons above and below the track, on the stock 1.12
scrollbar art cropped to its centred 16px (the `0.25..0.75` TexCoords of the reference's
`UIPanelScrollBarButton`).
-->
<Slider name="BenillaScrollBarTemplate" virtual="true">
<Size><AbsDimension x="16" y="0"/></Size>
@@ -322,39 +219,24 @@
</Slider>
<!--
The scroll bar's TROUGH — the recessed channel the bar rides in: the "background and
border" a bare arrows+knob bar is missing. An owner declares one of these BESIDE its bar,
named `<barname>Trough` and WITH NO ANCHORS — it seats itself on the bar the first time
BenillaScrollBar_SetShown touches it (BenillaScrollTrough_Seat above, where the socket law
and the ref citations live). Declare it BEFORE the bar so it draws beneath.
THE ART, and why it is sliced this way. `Interface\PaperDollInfoFrame\UI-Character-ScrollBar`
is a 64x256 sheet holding TWO 31-wide channel pieces, measured texel-exact from the file —
rails at x 2-5 and 25-30 with a 19-wide interior between them, and each piece bearing a
16-tall SOCKET for its arrow button:
· LEFT column (u 0 .. 31/64): rows 0-3 the solid TOP cap, 4-19 the up-arrow socket,
20-21 the crossbar that closes it, then plain channel from row 22 to the bottom edge.
· RIGHT column (u 33/64 .. 1): plain channel, then rows 85-86 the crossbar, 87-102 the
down-arrow socket, 104-106 the solid BOTTOM cap (nothing below 107).
The ref composes a trough from exactly two quads — the whole left column plus the right
column's bottom 108 rows — which reaches 362 units and no further, and only works because
its scroll frames are short enough for the two to OVERLAP. Ours must span a 563-tall page,
so the same art is cut as a THREE-SLICE instead: the two socket ends at their natural
heights and a plain-channel slice stretched between them. The middle is constant down its
whole length, so stretching it is exact — there is no seam to find.
The black `Fill` is the ref's own trough backing, not an invention: ItemTextFrame draws a
0.8-alpha black quad behind its channel art for exactly this (ref-ItemTextFrame.xml's
`Middle`). It sits in BACKGROUND and the channel art in BORDER — two layers, because within
ONE layer the client re-sorts overlapping quads by texture handle and their order is not
ours to depend on (order.rs). The bar's own furniture stays clear above both: button state
textures are ARTWORK, the knob is OVERLAY.
A bar's trough, the recessed channel it rides in; the reference's scroll kit has none.
Declare it before its bar, so it draws beneath, named `<barname>Trough` and with no
anchors: BenillaScrollTrough_Seat seats it on the bar.
`UI-Character-ScrollBar` (64x256) holds two 31-wide channel pieces, the left column
(u 0 .. 31/64) capped at the top and the right (u 33/64 .. 1) at the bottom, each with a
16-tall socket for its arrow button: rows 4-19 at the top, rows 87-102 at the bottom.
Deviation: cut as a three-slice (both socket ends at natural height, the plain channel
stretched between) where the reference overlaps two quads, since two quads reach only 362
units and a page here is 563 tall.
The black fill is the reference's trough backing (ItemTextFrame's 0.8-alpha `Middle`). It
is in BACKGROUND and the art in BORDER because the client orders overlapping quads within
one layer by texture handle; the bar's buttons (ARTWORK) and knob (OVERLAY) draw above both.
-->
<Frame name="BenillaScrollTroughTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="31"/></Size>
<Layers>
<Layer level="BACKGROUND">
<!-- The channel interior: x 6..24 of the 31-wide piece, i.e. inset 6 from each edge. -->
<!-- The channel interior: x 6..24 of the 31-wide piece, inset 6 from each edge. -->
<Texture name="$parentFill">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor>
@@ -374,9 +256,7 @@
<TexCoords left="0" right="0.484375" top="0" bottom="0.08984375"/>
</Texture>
<!-- Crossbar + down-arrow socket + cap: right column, rows 82..107
(v 82/256 .. 108/256) — the mirror of the top's 23, cut to put row 107 on the
trough's bottom edge exactly as ReputationFrame's own 108/0.421875 does, which
is what lands the socket on the arrow (BenillaScrollTrough_Seat's law). -->
(v 82/256 .. 108/256), ending on v 0.421875 as ReputationFrame's does. -->
<Texture name="$parentBottom" file="Interface\PaperDollInfoFrame\UI-Character-ScrollBar">
<Size><AbsDimension y="26"/></Size>
<Anchors>
@@ -386,8 +266,7 @@
<TexCoords left="0.515625" right="1.0" top="0.3203125" bottom="0.421875"/>
</Texture>
<!-- The stretched run between them: left column rows 30..70, plain rails only.
Declared LAST because it anchors to the two above by name, and an anchor whose
target is declared later falls back to the parent (ItemTextFrame's own note). -->
Declared last: an anchor to a later sibling falls back to the parent. -->
<Texture name="$parentMiddle" file="Interface\PaperDollInfoFrame\UI-Character-ScrollBar">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentTop" relativePoint="BOTTOMLEFT"/>
@@ -399,22 +278,4 @@
</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
from the day that entry landed and are gone (1860). What stays is
`BenillaScrollBarTemplate` + `BenillaScrollTroughTemplate` and their helpers, which are
ours: six windows inherit the bar directly and the reference has no trough at all. -->
</Ui>
@@ -1,27 +1,13 @@
<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.
BENILLA_ALLOW_OWN_UI=1. Deviation: over the unedited stock SpellBookFrame.xml, the pet
page's autocast widgets take the pet bar button's proportions.
The `$parentAutoCast` shine (UI-AutoCastButton.mdx) is squared on the button, 37x37 at
CENTER (0,0) and scale 1.48, so its rim runs on the viewport edge at the pet button's
1.024x and shares the brackets' centre; the reference's is 36x36 at CENTER (1,1), 1.22.
The corner brackets are 71.53 rather than 60: the art fills 33/64 of
UI-AutoCastableOverlay.blp, and 71.53 = 37 x 58/30 puts them on the button's corners at
the pet button's 0.997x. Deleting this file restores the reference's look.
-->
<Script><![CDATA[
for i = 1, 12 do
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -92,7 +92,7 @@ fn render_frames(frames: &[String]) -> String {
/// diff interesting, and the extra-names side the one nothing else in this harness can see — every
/// other ranking here is demand-driven, so it can only ever report what an addon *asked* for.
///
/// The reference side is `reference/1.12-globals.tsv`, itself generated from wow-re's live capture.
/// The reference side is `reference/1.12-globals.tsv`, generated from a live reference capture.
/// `lod` rows are the twelve LoadOnDemand `Blizzard_*` addons' names, unioned in by 1200 because a
/// live dump misses them unless the player opened those windows; they are counted separately rather
/// than silently folded in, since "absent from us" means something different for a window nobody
@@ -736,9 +736,8 @@ fn main() {
// **WHOSE package is incomplete** — printed immediately before the ranked blockers, because it
// is the line that stops a session hunting for a client bug that is not one. A `.toc` entry
// whose file the addon does not ship is the ADDON's defect, and the reference client's
// behaviour there is what ours already does: log `Couldn't open %s` and carry on
// (wow-re `ui/scratch/xml-toc-path-resolution.md` §4). Nothing is subtracted from the headline
// — 1213 — so both readings stay available.
// behaviour there is what ours already does: log `Couldn't open %s` and carry on (`0x6edaa0`).
// Nothing is subtracted from the headline — 1213 — so both readings stay available.
let own: Vec<&addon_harness::AddonReport> = reports
.iter()
.filter(|r| !r.absent_own_files.is_empty())
+28 -25
View File
@@ -115,10 +115,9 @@ pub struct AddonReport {
/// Not "did every entry resolve" — that is [`Self::absent_own_files`] and
/// [`Self::absent_foreign_files`], and it is a different question because the reference
/// answers it differently: a manifest line naming no file is `"Couldn't open %s"` in
/// FrameXML.log and the walk carries straight on (wow-re
/// `ui/scratch/xml-toc-path-resolution.md` §4, VERIFIED). An addon shipping an incomplete zip
/// *works* on a real client, so counting it here made the survey say the opposite of the truth
/// about 24 addons — FuBar itself among them, on two locale files it does not ship.
/// FrameXML.log and the walk carries straight on (`0x6edaa0`). An addon shipping an incomplete
/// zip *works* on a real client, so counting it here made the survey say the opposite of the
/// truth about 24 addons — FuBar itself among them, on two locale files it does not ship.
///
/// **[`Self::errors`] is unchanged and still carries those rows verbatim** (1213), so every
/// number in every past record is still readable off it; what changed, deliberately and with
@@ -144,11 +143,11 @@ pub struct AddonReport {
///
/// A different question from [`Self::absent_own_files`] and kept apart from it: this addon's
/// package is fine, it wants a neighbour. The corpus's two are Auctioneer and BeanCounter
/// reaching `..\Blizzard_AuctionUI\Blizzard_AuctionUITemplates.xml` — which wow-re records as
/// **RESOLVING** in the real client (`ui/scratch/xml-toc-path-resolution.md` §5 case 2, by
/// name), because there the file layer can see `Blizzard_AuctionUI` inside `patch.MPQ`. Ours
/// reads the AddOns directory only, so it misses. That one IS ours, and the split is what
/// makes it visible instead of averaging with the row above.
/// reaching `..\Blizzard_AuctionUI\Blizzard_AuctionUITemplates.xml` — which the real client
/// **RESOLVES** (`0x6ede10` collapses the `..`), because there the file layer can see
/// `Blizzard_AuctionUI` inside `patch.MPQ`. Ours reads the AddOns directory only, so it
/// misses. That one IS ours, and the split is what makes it visible instead of averaging
/// with the row above.
pub absent_foreign_files: Vec<String>,
/// Names it calls that the VM does not have — see the module doc on what this is worth.
pub missing_globals: Vec<String>,
@@ -1812,6 +1811,7 @@ fn seat_a_session(script: &mut UiScript) {
script.set_quest_log(QuestLogState {
entries: vec![header, in_progress, done],
num_quests: 2,
hidden_quest_ids: Vec::new(),
});
}
@@ -2083,11 +2083,10 @@ struct FileLoad {
///
/// It is `false` for an addon whose only failures are absent files, because on the reference
/// that addon *loads*: `0x6edaa0` logs `"Couldn't open %s"` and returns null, and the walk
/// carries on to the next manifest entry with nothing raised (wow-re
/// `ui/scratch/xml-toc-path-resolution.md` §4, VERIFIED — the same rule 2107 unified for the
/// two loaders that reach it from Lua). Until this existed the survey scored 24 corpus addons
/// as not surviving a session start on nothing but a locale file their own zip omits, FuBar
/// and 22 of its plugins among them.
/// carries on to the next manifest entry with nothing raised (the same rule 2107 unified for
/// the two loaders that reach it from Lua). Until this existed the survey scored 24 corpus
/// addons as not surviving a session start on nothing but a locale file their own zip omits,
/// FuBar and 22 of its plugins among them.
raised: bool,
}
@@ -2106,10 +2105,10 @@ fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> Fi
let Some(bytes) = read_under(root, &path) else {
errors.push(format!("{file}: not found"));
// WHOSE package is incomplete. `join_ref` has already collapsed the `..`s the way the
// client does (wow-re `ui/scratch/xml-toc-path-resolution.md` §2), so the resolved
// path is what the real file layer would be handed — and an entry that still points
// inside the addon's own folder is the addon shipping a manifest it does not satisfy,
// while one pointing out of it wants a neighbour that is not installed.
// client does (`0x6ede10`), so the resolved path is what the real file layer would be
// handed — and an entry that still points inside the addon's own folder is the addon
// shipping a manifest it does not satisfy, while one pointing out of it wants a
// neighbour that is not installed.
let own = path
.strip_prefix(base.as_str())
.is_some_and(|rest| rest.starts_with('/'));
@@ -4134,12 +4133,12 @@ mod dependency_tests {
/// are this, all one family: `DPSMate_CureDisease.toc` names eight files and the folder
/// holds six, because the three `*Received*` ones were split into a sibling addon and the
/// manifest was never updated. Our loader is behaving correctly (the reference logs
/// `Couldn't open %s` and carries on — wow-re `ui/scratch/xml-toc-path-resolution.md` §4).
/// `Couldn't open %s` at `0x6edaa0` and carries on).
/// - **`foreign`** — the entry escapes the addon's folder with `..`, which the client supports
/// and `join_ref` reproduces, into a folder that is not installed. The corpus's two are
/// Auctioneer and BeanCounter reaching `..\Blizzard_AuctionUI\...`, which wow-re records as
/// **RESOLVING** in the real client (§5 case 2, by name) because its file layer can see that
/// folder inside `patch.MPQ`. That one IS ours.
/// Auctioneer and BeanCounter reaching `..\Blizzard_AuctionUI\...`, which the real client
/// **RESOLVES** (`0x6ede10` collapses the `..`) because its file layer can see that folder
/// inside `patch.MPQ`. That one IS ours.
///
/// The assertion that matters is that the two never merge: a single "files not found" count
/// would average a broken package with a real client gap and read as one number.
@@ -4275,9 +4274,9 @@ mod dependency_tests {
// **NOTHING is subtracted from `errors`** (1213) — both rows are still there, verbatim, so
// every figure any past record quoted is still readable off this list. What changed in
// 2155 is which of them `loaded` counts: on the reference these addons LOAD, because
// `0x6edaa0` logs `Couldn't open %s` and the walk carries on with nothing raised
// (`ui/scratch/xml-toc-path-resolution.md` §4). Both halves are asserted together,
// because either one alone is a rule that has already been got wrong in both directions.
// `0x6edaa0` logs `Couldn't open %s` and the walk carries on with nothing raised. Both
// halves are asserted together, because either one alone is a rule that has already been
// got wrong in both directions.
for r in [short, wants] {
assert!(
r.loaded,
@@ -4405,6 +4404,7 @@ mod dependency_tests {
/// was an `inherits=` in their own XML, none of which appeared in the `CreateFrame` ranking.
#[test]
fn the_inherits_census_counts_templates_and_not_fonts() {
benilla_formats::wow_data_or_skip!();
let tmp =
std::env::temp_dir().join(format!("benilla-harness-inherits-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
@@ -4572,6 +4572,7 @@ mod dependency_tests {
/// which is exactly why the old table could not see any of it.
#[test]
fn the_per_kind_census_finds_a_verb_wired_to_the_wrong_kind() {
benilla_formats::wow_data_or_skip!();
let tmp =
std::env::temp_dir().join(format!("benilla-harness-perkind-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
@@ -4969,6 +4970,7 @@ mod dependency_tests {
/// rather than a guess.
#[test]
fn a_published_name_carries_its_kind() {
benilla_formats::wow_data_or_skip!();
let script = seated_vm();
assert_eq!(script.widget_kind("UIErrorsFrame"), Some("MessageFrame"));
assert_eq!(script.widget_kind("UIParent"), Some("Frame"));
@@ -5093,6 +5095,7 @@ mod dependency_tests {
/// (see `drive_ui_probe`'s own comment), and a probe that cannot fail is not a probe.
#[test]
fn the_ui_probe_hovers_and_records_what_a_tooltip_hook_raises() {
benilla_formats::wow_data_or_skip!();
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
seat_a_session(&mut script);
@@ -34,7 +34,7 @@
//!
//! - **Over-reports**: any quad from a new widget counts, including one drawn off-screen or under
//! another window. "Something reached the render list" is not "the player can see it"; the
//! director's eye is still the judge of that (the contract §7). Over-reporting is the deliberate
//! director's eye is still the judge of that (`docs/METHOD.md` §7). Over-reporting is the deliberate
//! direction — a silent under-report is the failure mode this whole column exists to end.
//! - **Under-reports** in one known shape: an addon that changes an *existing* widget in place —
//! `SetTexture` on one of our regions, `SetBackdrop` on one of our frames — creates no new
@@ -87,6 +87,7 @@ impl Drop for Fixtures {
/// not — and "creates a frame" is not "paints".
#[test]
fn the_render_column_can_fail() {
benilla_formats::wow_data_or_skip!();
let fx = Fixtures::new("cannotfail");
// Paints: a texture on a window of its own.
fx.addon(
@@ -179,22 +180,6 @@ fn the_render_column_can_fail() {
);
}
/// Where the corpus might be — the `ui_chat::ace_gate_tests` resolver, so a machine without the
/// third-party corpus skips rather than reddens.
fn corpus() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
let p = PathBuf::from(over);
if p.is_dir() {
return Some(p);
}
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
(2usize..=4)
.filter_map(|up| manifest.ancestors().nth(up))
.map(|root| root.join("wow-addons-vanilla"))
.find(|c| c.is_dir())
}
/// **The oracle: two real addons, opposite director-verified outcomes.**
///
/// `!OmniCC` puts cooldown countdown numbers on the director's screen — it works, and it works by
@@ -209,10 +194,9 @@ fn corpus() -> Option<PathBuf> {
/// If this test ever disagrees with the director's eyes, **the test is wrong**.
#[test]
fn the_directors_two_verified_addons_come_out_on_opposite_sides() {
let Some(corpus) = corpus() else {
eprintln!("skipping: no vanilla addon corpus (set $BENILLA_ADDON_CORPUS)");
return;
};
benilla_formats::wow_data_or_skip!();
// The one resolver, and a skip the gate can refuse (`benilla_formats::install`).
let corpus = benilla_formats::addon_corpus_or_skip!();
// A root holding just these four, SYMLINKED rather than copied. Surveying the whole corpus
// here would put a minute onto `cargo test --workspace` for four rows — the full sweep is the
// `addon_harness` example's job, not a unit test's.
@@ -248,22 +248,6 @@ fn the_use_column_can_fail() {
);
}
/// Where the corpus might be — the same resolver `render_tests` uses, so a machine without the
/// third-party corpus skips rather than reddens.
fn corpus() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
let p = PathBuf::from(over);
if p.is_dir() {
return Some(p);
}
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
(2usize..=4)
.filter_map(|up| manifest.ancestors().nth(up))
.map(|root| root.join("wow-addons-vanilla"))
.find(|c| c.is_dir())
}
/// **The real oracle: the director's two verified addons.**
///
/// - `!OmniCC` **works** — its countdown numbers are on their screen. It must not be reported as
@@ -278,10 +262,9 @@ fn corpus() -> Option<PathBuf> {
/// silently stops touching them is exactly how this instrument has been wrong four times.
#[test]
fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() {
let Some(corpus) = corpus() else {
eprintln!("skipping: no vanilla addon corpus (set $BENILLA_ADDON_CORPUS)");
return;
};
benilla_formats::wow_data_or_skip!();
// The one resolver, and a skip the gate can refuse (`benilla_formats::install`).
let corpus = benilla_formats::addon_corpus_or_skip!();
let fx = Fixtures::new("oracle");
for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] {
// MONKEY (volumetric fog): share the portable fixture used by the render oracle.
+13 -16
View File
@@ -3,8 +3,8 @@
//! `GetMinimapZoneText`/`GetZonePVPInfo` (decision 0287; corrected to the bytes by its fold-back
//! record).
//!
//! Byte-verified model (wow-re ui `zonetext-pvpinfo.md`; `0x494780` is the one updater all three
//! zone events fire from, on the per-update area resolve):
//! The model (`0x494780` is the one updater all three zone events fire from, on the per-update
//! area resolve):
//!
//! - The client caches `(zoneId, zoneText, subzoneText)` and compares per update: **zone id
//! changed → `ZONE_CHANGED_NEW_AREA` fires alone**; else **either text changed →
@@ -23,10 +23,10 @@
//! ownerless zone** (nil is structural failure only); `factionName` = FactionGroup.dbc's
//! localized Name for the zone's mask bit; pvpType is never "arena"; realm type never enters.
//! - **The indoor bit** is the player's faces-only down-ray verdict
//! ([`benilla_world::wmo_portal::CurrentAreaInterior`] — wow-re `zonetext-indoor-bit.md`, the CGLight
//! node's `+0x90` bit 0): indoors ⇔ the nearest surface below is a WMO face whose group lacks
//! MOGP `0x8` EXTERIOR. The abbey yard is terrain-below ⇒ outdoors; the flip is the doorway.
//! - **The indoor naming** (`0x67e670` (d), byte-pinned by (d-ii)): while indoors and the hit
//! ([`benilla_world::wmo_portal::CurrentAreaInterior`] — the CGLight node's `+0x90` bit 0, set
//! by `0x6a87f0`): indoors ⇔ the nearest surface below is a WMO face whose group lacks MOGP
//! `0x8` EXTERIOR. The abbey yard is terrain-below ⇒ outdoors; the flip is the doorway.
//! - **The indoor naming** (`0x67e670`): while indoors and the hit
//! GROUP is unchanged, the WHOLE updater is skipped (the dedup `[0x868608]`/`[0x86860c]`). On
//! an indoor change: **query A** (the whole-WMO −1 row, exact key, `0x69d830`) overrides the
//! ZONE slot — only when its resolved name is non-empty AND differs from the current subzone
@@ -43,9 +43,7 @@
//! 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.
//! than off the cache below.
//!
//! 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
@@ -110,9 +108,8 @@ struct ZoneCache {
minimap_text: String,
/// The previous update's indoor bit (`[0x868608]`).
indoor: bool,
/// The previous hit group's identity (`[0x86860c]` — the client stores the group index,
/// (d-ii); we scope the MOGP uniqueID by WMO): same group while indoors ⇒ the whole updater
/// is skipped.
/// The previous hit group's identity (`[0x86860c]` — the client stores the group index; we
/// scope the MOGP uniqueID by WMO): same group while indoors ⇒ the whole updater is skipped.
wmo_id: u32,
wmo_group: u32,
}
@@ -181,13 +178,13 @@ fn feed_zone_events(
leaf_row.name.clone()
};
// The indoor naming (`0x67e670` (d)/(d-ii), module doc): dedup-skip while the hit group
// The indoor naming (`0x67e670`, module doc): dedup-skip while the hit group
// holds; else the default-row name (query A) may override the zone slot and the group-row
// name (query B) re-populates the subzone — both EXACT-key lookups (no name-set retry).
let mut wmo_group = 0u32;
if let Some((k, group, default)) = world.area_interior_rows() {
// The dedup key: the hit GROUP's identity (the client's `[groupRec+0x7c]` group index,
// nonzero-gated — (d-ii)). We key the MOGP uniqueID scoped by WMO id: it changes exactly
// nonzero-gated). We key the MOGP uniqueID scoped by WMO id: it changes exactly
// when the hit group does, and — a named divergence — never aliases across two adjacent
// buildings the way the client's raw per-WMO index can.
wmo_group = k.group_area_id;
@@ -400,7 +397,7 @@ mod tests {
);
}
/// The indoor naming law (`0x67e670` (d)) against the REAL abbey/inn data shapes — the
/// The indoor naming law (`0x67e670`) against the REAL abbey/inn data shapes — the
/// override-skip (whole-WMO name == yard subzone) and the override-fire (inn) branches,
/// run through the same WmoAreaCatalog the live feed reads. Skips without client data.
#[test]
@@ -419,7 +416,7 @@ mod tests {
group.area_table_id, 24,
"the leaf stays Northshire Abbey (24)"
);
// The dedup rides the hit-group identity ((d-ii): the client's group index; our MOGP
// The dedup rides the hit-group identity (the client's group index; our MOGP
// uniqueID) — distinct per room, so a room hop re-fires and standing still skips.
let library = cat.group_row(59, 1, 1943).expect("library row");
assert_ne!(group.id, library.id, "distinct rows exist per room");
+1 -2
View File
@@ -13,8 +13,7 @@
//!
//! ## The law, from the reference
//!
//! **VERIFIED** against `0x5e2110` (the per-frame check) and `0x5e22d0` (containment), wow-5875-re
//! `object-layer/scratch/w2b1-decomp.c`; the geometry itself is
//! From `0x5e2110` (the per-frame check) and `0x5e22d0` (containment); the geometry itself is
//! [`AreaTriggerRow::contains`](benilla_formats::AreaTriggerRow::contains).
//!
//! - The map's rows are the only candidates (`0x5e2080` narrows the map-sorted table to a
+22 -26
View File
@@ -9,7 +9,7 @@
//! kit carries no effect models and no anim at all — its entire visual is one CharProc — so the
//! effects-only watcher resolved the kit, found an empty slot list, and dropped it on the floor.
//!
//! ## The mechanism (VERIFIED — wow-re `ghost-death-visuals.md` §2, `state-kit-aura-lifecycle.md`)
//! ## The mechanism
//!
//! `UNIT_FIELD_AURA` slot change → the aura watcher `0x604d00` → `0x6123f0` → **`0x5ff350`
//! PlayAuraStateVisual**: the slot's spell → `Spell.dbc` → `SpellVisual` → **field 4 = the state
@@ -21,10 +21,9 @@
//! - **proc 14 = translucency** (`0x60d972`): the param becomes a node keyed by spell id
//! (`node+0x18`, value at `node+0x78`), linked at the HEAD of the unit's list `unit+0xb50`. The
//! unit's effective alpha is then recomputed by `0x60d180` as **`baseAlpha × the head node's
//! factor`** — at most one node term, never a product over the chain (wow-re
//! `base-render-alpha.md` §4, correcting this note's earlier `Π` gloss) — `baseAlpha` from the
//! factor`** — at most one node term, never a product over the chain — `baseAlpha` from the
//! vtbl+0x6c getter `0x60d2d0` (`CreatureDisplayInfo+0x14 × 1/255`, the SAME slot in the unit
//! and player vtables: "players 1.0" was the data talking, not a type fork — §6) — and handed
//! and player vtables: a player's 1.0 is the data talking, not a type fork) — and handed
//! to **`0x614f80` StartAlphaFade(target, 1000 ms)**, the same ramp block (`+0xec..0x100`) and
//! the same `clamp01(t)³` ease (`0x614a90`) our appear-fade already rides
//! ([`benilla_world::model_fade::fade_alpha`]). Per frame `model+0x180 = master × fade`
@@ -41,10 +40,10 @@
//!
//! The vis-flag is a decoy and must not be used here: `UNIT_FIELD_BYTES_1` byte 3's CREEP (stealth)
//! and GHOST bits drive **no** body render at all — an exhaustive census of every `+0x213` reference
//! found three sites, all nameplate/marker suppression (wow-re `ghost-death-visuals.md` §1). The two
//! co-travel only because the server sets the flag and the aura from the same spell. Keying the body
//! off the flag would be the wrong mechanism that happens to look right on stealth and then fails on
//! every other member of the family.
//! found three sites, all nameplate/marker suppression (`0x5ff80d`, `0x607101`, `0x60f62e`). The
//! two co-travel only because the server sets the flag and the aura from the same spell. Keying
//! the body off the flag would be the wrong mechanism that happens to look right on stealth and
//! then fails on every other member of the family.
//!
//! ## The shipped data (read from the real 5875 `SpellVisualKit.dbc` this session —
//! `benilla-extract charprocs` censuses it)
@@ -96,7 +95,7 @@
//!
//! Types 2, 7 and 13 also appear on state kits (Berserk/Bloodlust's 2, the Sap/Feign-Death 7, the
//! "Glowy (Red)" 13) and have **no verified mechanism** — they fall through [`node_for`]'s single `_`
//! arm, which is where each lands as one match arm the day its RE returns.
//! arm, which is where each lands as one match arm the day its mechanism is known.
use benilla_protocol::EntityKind;
use bevy::mesh::MeshTag;
@@ -154,10 +153,9 @@ pub(crate) struct AuraNodes {
/// driver's own again.
rate: Vec<(u32, f32)>,
/// `baseAlpha`: the display row's `CreatureModelAlpha / 255` — for EVERY unit, players
/// included; the getter `0x60d2d0` has no type fork, a normal character's row just says 255
/// (`base-render-alpha.md` §6, correcting the "players 1.0" gloss). Owned by
/// [`refresh_base_alpha`], which re-resolves it on every display-id change (a shapeshift
/// swaps it live) and ramps to the new value.
/// included; the getter `0x60d2d0` has no type fork, a normal character's row just says 255.
/// Owned by [`refresh_base_alpha`], which re-resolves it on every display-id change (a
/// shapeshift swaps it live) and ramps to the new value.
base: f32,
/// Where the ramp is now — what the parts actually render at.
current: f32,
@@ -188,8 +186,8 @@ impl AuraNodes {
/// The reference's `0x60d180`: `baseAlpha × the HEAD alpha node's factor` — at most ONE node
/// term, skipped entirely when the list is empty (`0x60d195 je`), never a product over the
/// chain (wow-re `base-render-alpha.md` §4, correcting `ghost-death-visuals.md`'s `Π` gloss).
/// Nodes are linked at the head as they install, so the newest one is the term that counts.
/// chain. Nodes are linked at the head as they install, so the newest one is the term that
/// counts.
fn target(&self) -> f32 {
let head = self.alpha.first().map_or(1.0, |(_, f)| *f);
(self.base * head).clamp(0.0, 1.0)
@@ -365,7 +363,7 @@ pub(crate) struct AnimRateFreeze;
/// inherit the freeze and outlive it.
///
/// **Reasserted every frame, and that is the faithful shape, not a belt-and-braces clamp.** The
/// reference's rate lives on the bone (`+0xb0`, the factor `timebase.md` §2's clock multiplies its
/// reference's rate lives on the bone (`+0xb0`, the factor the clock at `0x71458e` multiplies its
/// window by) and **arming an animation does not touch it**: op4 `0x7121a0`'s success leg writes the
/// track index and the cursors and leaves `+0xb0` alone (only its disarm leg and `SetBoneAnimSpeed
/// 0x712910` write it). So "rate 0 until the aura is reaped" is literally the reference's state
@@ -493,7 +491,7 @@ pub(crate) fn node_for(proc: benilla_formats::CharProc) -> Option<AuraNode> {
///
/// `base_alpha` here only SEEDS a node set created by an aura edge — its steady-state owner is
/// [`refresh_base_alpha`], which re-resolves it on every display change. The getter is the same
/// for players and creatures (the display row's `CreatureModelAlpha`; `base-render-alpha.md` §6).
/// for players and creatures (the display row's `CreatureModelAlpha`, read by `0x60d2d0`).
/// A unit whose display is unknown falls back to `1.0` — opaque, i.e. the aura's factor alone,
/// which is the safe direction (the alternative would hide a unit whose row simply failed to
/// load).
@@ -566,9 +564,8 @@ fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) {
/// Install one spell's nodes, replacing any it already had (an aura re-applied by a second caster
/// holds a second slot but installs one node set — the reference's own per-spell-id keying).
/// New nodes link **at the head** (`base-render-alpha.md` §4: the newest node is the one the
/// recompute reads), which is why both lists insert at the front here and both readers take
/// `.first()`.
/// New nodes link **at the head** (the newest node is the one the recompute `0x60d180` reads),
/// which is why both lists insert at the front here and both readers take `.first()`.
fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) {
n.alpha.retain(|(s, _)| *s != spell_id);
n.tint.retain(|(s, _)| *s != spell_id);
@@ -587,7 +584,7 @@ fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) {
/// the spell, the nodes it installed, and the target the ramp is now aiming at.
///
/// This is how "stealth shows nothing" is *reproduced and then closed* without eyeballing a capture
/// (`method.md` §5/§6): with the aura layer dead the trace is silent on `.cast 1784`; with it live
/// (`docs/METHOD.md` §5/§6): with the aura layer dead the trace is silent on `.cast 1784`; with it live
/// the same press prints `aur arm spell=1784 alpha=[0.3] -> base 1.00 target 0.30`, and the drop
/// prints the ramp back to 1.00. It rides the shared move-trace sink so an aura edge interleaves on
/// one timeline with the movement/anim lines — a "why did it not fade when I moved" question needs
@@ -616,10 +613,9 @@ fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) {
/// `baseAlpha` for a unit — the getter `0x60d2d0`: its CURRENT display row's
/// `CreatureModelAlpha / 255`, for **every** unit kind. There is no player override — the same
/// vtable slot in both classes, byte-identical (`base-render-alpha.md` §6); an unshifted
/// character reads 1.0 only because its row (49, 50, …) says 255, and a shaman wearing Ghost
/// Wolf's display 4613 reads 102/255 = 0.4 through exactly this path. No row → 1.0 (the
/// `0x60d2e4` NULL fallback).
/// vtable slot in both classes, byte-identical; an unshifted character reads 1.0 only because its
/// row (49, 50, …) says 255, and a shaman wearing Ghost Wolf's display 4613 reads 102/255 = 0.4
/// through exactly this path. No row → 1.0 (the `0x60d2e4` NULL fallback).
fn base_alpha(
entity: Entity,
units: &Query<&crate::net::NetEntity>,
@@ -646,7 +642,7 @@ fn display_base_alpha(
}
/// Re-resolve every unit's base alpha when its display changes — the reference's DISPLAYID
/// watcher path (`base-render-alpha.md` §5: `0x604990 → 0x60abe0 → 0x60afb0` caches the row →
/// watcher path (`0x604990 → 0x60abe0 → 0x60afb0` caches the row →
/// `0x60ad9f → 0x60d180 → StartAlphaFade(target, 1000 ms)`), self-gated on a real record change
/// (`0x60ae10`). This is what makes a unit with `CreatureModelAlpha < 255` translucent with **no
/// aura in play** — the shifted shaman's ghost look — and what ramps it back to opaque when the
+6 -7
View File
@@ -66,8 +66,8 @@ fn the_dispatch_names_only_the_verified_procs() {
Some(AuraNode::Alpha(0.0)),
"kit 5129's 0.0 is a real value, not an absent one"
);
// 9222653 = 0x8CB9FD — the ghost's pale blue-white, byte-cited in wow-re as 0xFF8CB9FD after the
// client ORs the opaque byte on.
// 9222653 = 0x8CB9FD — the ghost's pale blue-white, 0xFF8CB9FD once the client ORs the opaque
// byte on (`0x60d8cc`).
assert_eq!(
node_for(proc(char_proc_type::TINT, 9_222_653.0)),
Some(AuraNode::Tint([0x8C, 0xB9, 0xFD])),
@@ -99,10 +99,9 @@ fn the_dispatch_names_only_the_verified_procs() {
// ── The target and the ramp ──────────────────────────────────────────────────────────────────────
/// `0x60d180`: the target is `baseAlpha × the HEAD node's factor` — at most one node term, the
/// newest (nodes link at the list head as they install), never a product over the chain (wow-re
/// `base-render-alpha.md` §4, correcting the earlier `Π` reading). Stacking stealth (0.3) then the
/// ghost aura (0.5) reads 0.5 — whichever landed last — and dropping the head hands the term to
/// the next node down.
/// newest (nodes link at the list head as they install), never a product over the chain.
/// Stacking stealth (0.3) then the ghost aura (0.5) reads 0.5 — whichever landed last — and
/// dropping the head hands the term to the next node down.
#[test]
fn the_target_is_base_times_the_newest_node() {
let mut n = AuraNodes::new(1.0);
@@ -135,7 +134,7 @@ fn the_target_is_base_times_the_newest_node() {
/// The display-swap leg ([`refresh_base_alpha`]'s retarget): a base change rides the same 1000 ms
/// cubic ramp an aura node does — Ghost Wolf's 102/255 eases in from opaque, and swapping home
/// eases back — never snaps (`base-render-alpha.md` §5: the fade is part of the mechanism).
/// eases back — never snaps (the fade, `0x614f80`, is part of the mechanism).
#[test]
fn a_base_change_rides_the_same_ramp() {
let wolf = 102.0 / 255.0;
+59 -19
View File
@@ -42,7 +42,7 @@
//! on the confirmed switch back to general — the reference's own semantics.
use bevy::input::keyboard::KeyboardInput;
use bevy::input::mouse::AccumulatedMouseScroll;
use bevy::input::mouse::{AccumulatedMouseScroll, MouseScrollUnit};
use bevy::input::ButtonState;
use bevy::prelude::*;
@@ -434,6 +434,51 @@ fn sync_dispatch(script: Option<NonSendMut<UiScript>>, mut dispatch: ResMut<Bind
script.fire_event("UPDATE_BINDINGS", vec![]);
}
/// A wheel delta in **lines** — notches — whatever unit the OS reported it in. A mouse wheel
/// arrives as `Line`; a macOS trackpad or Magic Mouse arrives as `Pixel`, many small deltas a
/// frame apart for one gesture, and Bevy's own conversion factor is the one normalisation.
/// Shared by every wheel reader so none of them keeps its own copy of the constant.
pub(crate) fn wheel_lines(unit: MouseScrollUnit, dy: f32) -> f32 {
match unit {
MouseScrollUnit::Line => dy,
MouseScrollUnit::Pixel => dy / MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR,
}
}
/// **Whole notches out of a stream of line deltas**, carrying the fraction between frames.
///
/// For the consumers that act per NOTCH rather than per unit of travel — the UI's
/// `OnMouseWheel` (stock `ScrollFrameTemplate_OnMouseWheel` moves half a pane per call and
/// `ChatFrame_OnMouseWheel` a line, both off the sign alone) and the realm list's row step. Fed a
/// trackpad's per-frame pixel trickle raw, each of those fired once per frame, so a gentle swipe
/// slammed a pane to its end; through this, ten frames of a tenth of a line are one notch.
///
/// A reversal drops the carried fraction: travel the other way is a new gesture, and it should
/// not first have to undo the remainder of the last one.
///
/// A `Resource` for the UI's feed (a fact about the input device's gesture, which outlives any one
/// VM), a `Local` for the realm list's.
#[derive(Default, Resource)]
pub(crate) struct WheelNotches {
carry: f32,
}
impl WheelNotches {
/// Add `lines` of travel; returns the signed whole notches it completes (usually 0 or ±1).
pub(crate) fn feed(&mut self, lines: f32) -> i32 {
if lines == 0.0 || !lines.is_finite() {
return 0;
}
if self.carry != 0.0 && self.carry.signum() != lines.signum() {
self.carry = 0.0;
}
self.carry += lines;
let whole = self.carry.trunc();
self.carry -= whole;
whole as i32
}
}
/// The dispatch pass — see the module doc. Runs right after the UI key feed (same frame's
/// capture gate), before `WorldStage::Input` (a bound key must act this frame, once).
fn latch_and_dispatch(
@@ -540,8 +585,7 @@ fn latch_and_dispatch(
// 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).
// window-deactivate** handler, not a UI-focus one.
// 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
@@ -681,19 +725,14 @@ fn latch_and_dispatch(
// ── Wheel ── **a notch is a press AND its release, back to back.** The reference builds one
// chord and hands it to `CBindings::ExecuteBinding` twice — `isDown=1` at `0x483d6f`, then
// `isDown=0` at `0x483d82` (wow-re `system/ui/ui.md` §3, VERIFIED) — so a `runOnUp` command
// `isDown=0` at `0x483d82` — so a `runOnUp` command
// runs both halves in the same frame and a plain one runs its single half (the up leg is the
// `RunCommand 0x4b7b50` no-op: `UP + !runOnUp` returns without running anything). Before this
// the notch was a press with no release, which quietly made every press+release command a
// dead wheel binding. Over UI the wheel belongs to the hovered frame.
// Trackpads report pixel deltas — normalized to line-equivalents so the zoom consumer's
// feel is unchanged from when it read the scroll itself.
let wheel = match scroll.unit {
bevy::input::mouse::MouseScrollUnit::Line => scroll.delta.y,
bevy::input::mouse::MouseScrollUnit::Pixel => {
scroll.delta.y / bevy::input::mouse::MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR
}
};
// Trackpads report pixel deltas — normalized to line-equivalents ([`wheel_lines`]) so the
// zoom consumer's feel is unchanged from when it read the scroll itself.
let wheel = wheel_lines(scroll.unit, scroll.delta.y);
// **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
@@ -891,9 +930,10 @@ fn release(
/// **Restoring it is not tidiness.** `keystate` is absent from the 1.12.1 client's in-world `_G`
/// (`reference/1.12-globals.tsv`) even though its own `Bindings.xml` bodies read it as a bare
/// global — which is only possible if the reference sets it transiently around the call, exactly
/// as it does `this`/`event`/`arg1` (`invoke_with_globals`, RF-0025). Leaving it set would hand
/// every addon a global the reference does not have, and an addon that feature-detects it would
/// take a path we cannot honour — decision 1189's "a superset is not free", one call deeper.
/// as it does `this`/`event`/`arg1` (`invoke_with_globals`; `0x703f50` → `0x704f10`). Leaving it
/// set would hand every addon a global the reference does not have, and an addon that
/// feature-detects it would take a path we cannot honour — decision 1189's
/// "a superset is not free", one call deeper.
///
/// Save-and-restore rather than set-and-delete, because these bodies nest: a binding whose Lua
/// fires another binding must not clear the outer one's `keystate` on the way out.
@@ -1119,10 +1159,9 @@ mod tests {
/// strata walk carries the key down to `CGWorldFrame`, and `ExecuteBinding` runs `TURNLEFT`.
/// The reference's own chat box ships the flag, so this is the default experience.
///
/// benilla read the flag as "consumed but inert, unless Ctrl" until the §5
/// (`ignorearrows-alt-arrow-gate.md`) corrected both halves — the modifier is ALT, and the key
/// is not consumed at all. Under the old reading, holding LEFT with the chat box open did
/// nothing whatever.
/// benilla read the flag as "consumed but inert, unless Ctrl" until the bytes corrected both
/// halves — the modifier is ALT, and the key is not consumed at all. Under the old reading,
/// holding LEFT with the chat box open did nothing whatever.
#[test]
fn a_flagged_editbox_lets_the_arrow_keys_through_to_their_bindings() {
let mut app = harness();
@@ -1272,6 +1311,7 @@ mod tests {
/// tests carry no page — between them the join was never asserted.
#[test]
fn a_wheel_notch_binds_through_the_real_page_and_then_dispatches() {
benilla_formats::wow_data_or_skip!();
let by_name =
|n: &str| Cmd(SPECS.iter().position(|s| s.name == n).expect("registered") as u16);
let mut s = crate::ui_script::keybindings_tests::harness();
+6 -6
View File
@@ -176,8 +176,8 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
// **On a Mac keyboard the print-screen key IS F13 — read out of the Mac binary now, not
// inferred.** The 1.12.1 Mac slice's key table `0x5bf320` maps Mac virtual keycode `0x69`
// (F13) to `0x212`, the same `PRINTSCREEN` the Windows table reaches from `VK_SNAPSHOT`.
// (This arm shipped as an inference off `KEY_PRINTSCREEN_MAC = "F13"`; wow-re's
// `keycode-origin-law.md` settled it at the bytes.) macOS has no PrintScreen keycode at
// (This arm shipped as an inference off `KEY_PRINTSCREEN_MAC = "F13"`; the Mac table
// settled it at the bytes.) macOS has no PrintScreen keycode at
// all, so without this the byte-real `PRINTSCREEN SCREENSHOT` default is a dead key on
// every Mac — not fidelity, a broken key.
#[cfg(target_os = "macos")]
@@ -252,10 +252,10 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
NumpadEqual => "NUMPADEQUALS",
NumLock => "NUMLOCK",
PrintScreen => "PRINTSCREEN",
// ScrollLock and Pause are deliberately absent: the reference's namer calls their key
// codes (`0x210`/`0x211`) `UNKNOWN`, so they are unbindable there (wow-re
// `keybinding-dispatch-law.md` §2.3), and `IsValidBindingKeyString` would refuse the
// names anyway — arm 4's 26-name table holds neither.
// ScrollLock and Pause are deliberately absent: the reference's namer (`0x4b66b0`) calls
// their key codes (`0x210`/`0x211`) `UNKNOWN`, so they are unbindable there, and
// `IsValidBindingKeyString` (`0x4b7890`) would refuse the names anyway — its 26-name table
// holds neither.
CapsLock => "CAPSLOCK",
Minus => "-",
Equal => "=",
+37 -4
View File
@@ -1149,10 +1149,10 @@ const TABLE: &[Spec] = &[
// shot's confirmation, then ask the engine.
//
// `PRINTSCREEN` comes from the SHIPPED default, not a player's cache: `DefaultBindings.wtf`
// lives inside `patch.MPQ` and its line 128 is `bind PRINTSCREEN SCREENSHOT` (wow-re's
// dispatch on this feature; the install's account-ONE `bindings-cache.wtf` agrees). `Edge`,
// not `EdgeUpDown`, is also byte-real: the `<Binding>` carries no `runOnUp`, and the
// reference's dispatcher returns on key-up unless that flag is set (`0x4b7bea`).
// lives inside `patch.MPQ` and its line 128 is `bind PRINTSCREEN SCREENSHOT` (the install's
// account-ONE `bindings-cache.wtf` agrees). `Edge`, not `EdgeUpDown`, is also byte-real: the
// `<Binding>` carries no `runOnUp`, and the reference's dispatcher returns on key-up unless
// that flag is set (`0x4b7bea`).
//
// 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.
@@ -2219,6 +2219,7 @@ mod tests {
/// here as the witness for it.
#[test]
fn the_global_scanner_sees_both_kinds_of_definition() {
benilla_formats::wow_data_or_skip!();
let defined = lua_globals_defined();
for host in ["TargetUnit", "UseAction", "SetBinding"] {
assert!(
@@ -2252,6 +2253,38 @@ mod tests {
);
}
/// The parser's own header, read against the file it describes. `benilla_ui::bindings_xml`
/// quotes the shape of `Interface\FrameXML\Bindings.xml` — 228 live bindings, 94 `runOnUp`,
/// 13 `header`, 12 `hidden`, the commented-out `MOVEVIEW*` family that a text search counts and
/// a parser must not — and until 2331 the test that pinned those numbers lived beside the
/// parser, keyed on a `BENILLA_BINDINGS_XML` path nothing ever set, so it had never run. It
/// lives here because here is where the install is (`install_bindings`, off the player's own
/// chain); a wrong count is either a wrong file or a header that drifted from the client.
#[test]
fn the_installs_bindings_xml_reads_as_the_parsers_header_says() {
let Some(binds) = install_bindings() else {
return;
};
assert_eq!(
binds.len(),
228,
"1.12.1 ships 228 live bindings — the other six are inside a comment"
);
assert!(binds.iter().all(|b| !b.name.is_empty()));
assert!(
!binds.iter().any(|b| b.name.starts_with("MOVEVIEW")),
"the commented-out family must not register"
);
assert_eq!(binds.iter().filter(|b| b.run_on_up).count(), 94);
assert_eq!(binds.iter().filter(|b| b.header.is_some()).count(), 13);
assert_eq!(binds.iter().filter(|b| b.hidden).count(), 12);
assert_eq!(
binds[0].header.as_deref(),
Some("MOVEMENT"),
"the file opens on the MOVEMENT section"
);
}
/// The install's own `Bindings.xml`, parsed — `None` (and a skipped test) without a client.
fn install_bindings() -> Option<Vec<benilla_ui::bindings_xml::AddonBinding>> {
let data = benilla_formats::wow_data_or_skip!(None);
+20 -23
View File
@@ -2,8 +2,7 @@
//! creature), the reference's per-frame shadow pass rebuilt on the shared surface-decal projector
//! ([`benilla_world::decal`]), drawn on the shared effect stream (0733).
//!
//! **The byte-verified mechanism** (wow-re `unit-blob-shadow.md`, a §5 cross-check; the "cloud
//! shadow" label on `0x6d7920` was corrected — it IS the unit shadow draw):
//! **The mechanism** (`0x6d7920` IS the unit shadow draw):
//! - **Draw path**: a per-frame pass over registered model nodes (`0x683dd0`, list `[0xc7cb10]`)
//! → gate `0x6d78f0` (model streamed, master toggle) → `0x6d7920` → the **same decal chain the
//! selection ring uses** (`0x6d7330 → 0x6d6fa0 → 0x6d7480`), collector flags `0x2f0122` = the
@@ -11,15 +10,12 @@
//! [`GroundDecalSurface`] set yet — the shadow lands on terrain + WMO faces only).
//! - **Frame slot**: PHASE 1, among the opaque drains — `0x6812c5 call 0x683dd0`, fifth of the
//! 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 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`). 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
//! `0x48361d`. `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 draws its shadow second
//! (`0x683ec3`) — so per unit the additive ring goes down and this modulate darkens it, never the
//! other way round. That second step is **not** a gate: both exits of the tick `0x481540` return
//! 1, so `0x683ea5`'s `je` is dead. 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
@@ -29,17 +25,17 @@
//! - **Texture**: `Textures\ShadowBlob.blp` — a 32×32 grayscale radial blob (flat gray-160 core,
//! linear rim to white) under a binary alpha disc. The reference multitextures a procedural 64×8
//! trapezoid ramp on a second stage (`0x6d81a0`/`0x6d82d0`, blend-mode-selected); its combine
//! wiring is an open RE item (apitrace) — here the ramp is the vertex-alpha vertical fade below.
//! wiring is open (an apitrace question) — here the ramp is the vertex-alpha vertical fade below.
//! - **Box law** (`0x711a20` + the `0x6d7920` corner build): a sequence CAaBox, clamped INTO ±5
//! per axis (a cap, never a floor), scaled by the world matrix, yaw-rotated with the unit's
//! facing then **axis-aligned-bounded**. Vertical about the model origin: `+1.0·(zExt/2)` up,
//! `−(5/3)·(zExt/2)` down. A degenerate horizontal box is the reference's no-op exit (no
//! shadow). **No** `OBJECT_FIELD_SCALE_X` re-read (the transform scale already carries it), no
//! ring-style `sqrt` compression, no floor. **WHICH sequence — settled at bytes + pixels**
//! (decision 0316; wow-re `27406d9b`, Q3-ORACLE): the draw re-reads
//! (decision 0316): the draw re-reads
//! `playableAnimationLookup[0]` every frame — **slot 0 = Stand for characters, from the file
//! image, so the value never changes** (not the playing sequence: the director's gait-stable
//! observation falsified that first reading, and the trace oracle confirmed — 1,682 measured
//! observation falsified that first reading, and reference captures confirmed — 1,682 measured
//! draws, six bit-stable box sizes, HumanMale 0.9134 × 1.0805 yd permanently, Walk/Run extents
//! 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
@@ -51,7 +47,7 @@
//! 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
//! **What rides in, at the bytes** (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
@@ -70,7 +66,7 @@
//! - **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, and **which** objects
//! register is now settled (wow-re, 2026-09-06 — the old `HANDOFF(-> object-layer)` is closed):
//! register is now settled (2026-09-06):
//! `[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
@@ -108,7 +104,7 @@ use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::view::WorldCamera;
use benilla_world::wmo_portal::UnitWmoRoom;
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, wow-re unit-blob-shadow RE): grayscale
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, created by `0x6d8070`): grayscale
/// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground.
const SHADOW_TEXTURE: &str = "mpq://textures/shadowblob.blp";
/// The byte clamp on the animation box: each corner component is clamped INTO ±5 yd pre-scale
@@ -183,8 +179,8 @@ fn setup_shadow_assets(mut commands: Commands, asset_server: Res<AssetServer>) {
/// Keep one shadow record per eligible unit: spawn for new Player/Unit entities whose model has
/// built (an animated model — [`ModelAnimations`] arrives with it), despawn orphans (owner
/// destroyed / streamed out). The registration *policy* is the open RE item; this is the v1 set
/// (see module docs).
/// destroyed / streamed out). The registration *policy* is still open; this is the v1 set (see
/// module docs).
#[allow(clippy::type_complexity)] // the filtered spawn-gate query, commented inline
fn sync_shadows(
mut commands: Commands,
@@ -277,7 +273,7 @@ fn update_shadows(
// mount's rendered scale while a mount model is attached (the mount IS the footprint on the
// ground; the rider's box would undersize it). The mount-vs-body source of the client's own
// shadow box is untraced — this is the named approximation of decision 0441's P2, carried
// until a wow-re shadow-consumer trace pins it.
// until that source is known.
mount_anims: Query<(&NetEntity, &ModelAnimations), With<crate::entities::mount::MountBody>>,
mut shadows: Query<(&BlobShadow, &mut ShadowKey, &mut ShadowVerts)>,
// Once-a-second census at debug level (`RUST_LOG=benilla_app::blob_shadow=debug` — the lib
@@ -351,7 +347,7 @@ fn update_shadows(
Some((mnet, manims)) => (manims, mnet.scale),
None => (anims, 1.0),
};
// The byte+pixel law (0316, wow-re 27406d9b): the box is playableAnimationLookup[0]'s
// The byte+pixel law (0316): the box is playableAnimationLookup[0]'s
// sequence — Stand, permanently (the reference re-reads it per frame from the file image;
// the value can't change). resolve(0) walks the same baked table, so Stand-less models
// land on their substitute exactly like the binary's row-0 fast path.
@@ -521,8 +517,9 @@ fn update_shadows(
.map(|v| format!("({:.3},{:.3})", v.uv[0], v.uv[1]))
.collect();
debug!("self shadow uvs: {}", uvs.join(" "));
// And the box/rect numbers: the oracle says HumanFemale's footprint is 0.77x0.74 yd
// nearly centred; a bigger or offset rect indicts the box math, not the projector.
// And the box/rect numbers: measured on the reference client, HumanFemale's
// footprint is 0.77x0.74 yd nearly centred; a bigger or offset rect indicts the box
// math, not the projector.
debug!(
"self shadow box: bmin {:?} bmax {:?} rect x [{:.3}, {:.3}] z [{:.3}, {:.3}] \
(extent {:.3}x{:.3}, centre offset ({:.3}, {:.3}))",
+9 -10
View File
@@ -1,14 +1,13 @@
//! The engine-drawn **bowstring** (wow-re `nocked-ammo-cancel.md` §G2, byte-verified): bow M2s
//! carry NO string geometry — the real client registers a bow-only per-frame draw callback
//! (`0x611ff0`) that spans the bow's `$WTT`/`$WTB` limb-tip event markers with a **2-segment
//! line list**, middle vertex at the character's HandArrow attach while the nock latch
//! (`[+0xd58] & 0x4000`) holds, else the tip midpoint — so the string tracks the draw hand while
//! nocked and relaxes to a straight chord at rest.
//! The engine-drawn **bowstring**: bow M2s carry NO string geometry — the real client registers a
//! bow-only per-frame draw callback (`0x611ff0`) that spans the bow's `$WTT`/`$WTB` limb-tip event
//! markers with a **2-segment line list**, middle vertex at the character's HandArrow attach while
//! the nock latch (`[+0xd58] & 0x4000`) holds, else the tip midpoint — so the string tracks the
//! draw hand while nocked and relaxes to a straight chord at rest.
//!
//! Benilla's transcription draws the two segments through Bevy's gizmo lines — the same
//! immediate-mode screen-space-width primitive class as the client's GX lines. Named
//! deviation (decision record): the string color/width are INFERRED (the callback emits a
//! packed vertex color the round didn't decode — a dark cord is used).
//! deviation (decision record): the string color/width are inferred (the callback emits a
//! packed vertex color that is not decoded — a dark cord is used).
//!
//! **The tips are POSED** (decision 2281). The bow prop animates — `$BWP` arms BowPull(160) on the
//! prop's own model and `$BWR` returns it to Stand(0) — so the `$WTT`/`$WTB` markers ride limb
@@ -21,7 +20,7 @@ use bevy::prelude::*;
use crate::creature_anim::NockLatch;
use crate::entities::BoneAttach;
/// The HandArrow attach id (35 — wow-re §E2/G2): the string's middle control point while nocked,
/// The HandArrow attach id (35 — `0x6121b8`): the string's middle control point while nocked,
/// the same point the nocked-arrow model rides.
const HAND_ARROW: u16 = 0x23;
@@ -55,7 +54,7 @@ fn draw_bowstrings(
joints: Query<&GlobalTransform>,
mut gizmos: Gizmos,
) {
// A dark waxed-cord tone; the ref's packed vertex color is not decoded (INFERRED, §G2).
// A dark waxed-cord tone, inferred: the ref's packed vertex color is not decoded.
const STRING_COLOR: Color = Color::srgb(0.12, 0.10, 0.08);
for (bs, prop, vis, flex) in &bows {
if !vis.get() {
+8 -8
View File
@@ -3,8 +3,8 @@
//!
//! **Four producers, two id spaces, one evaluator.** Every producer ends at the same spawner —
//! `AddShake(id, worldPos)` (`0x511d40`), whose five call sites are the whole population (proven by
//! xref sweep, wow-re `spell/scratch/camera-shake-producers.md` §Q4: no address-dword reference to
//! it anywhere, and no Lua, console, cinematic or quake path).
//! xref sweep: no address-dword reference to it anywhere, and no Lua, console, cinematic or quake
//! path).
//!
//! The **creature** pair names a `CameraShakes.dbc` preset **directly**:
//!
@@ -33,7 +33,7 @@
//! thumping-giant list; 49 carry one of the two, and 58 of the 1772 kits carry a shake group. See
//! `benilla-extract shakecensus`, which maps all four producers onto the 24 presets.
//!
//! ## The law (wow-re `ui/scratch/camera-shake-law.md`, §5-verified)
//! ## The law
//!
//! **Emission — the footstep**, gated in this order (`0x5fbf70`): not hovering · `BYTES_1` byte 3
//! bit `0x2` clear (our stealth bit) · not a player-ghost · **`|camera − footplant|² ≤ 2500`
@@ -171,7 +171,7 @@ impl CameraShakes {
/// Retire what has expired and compose this frame's offset.
///
/// `suspended` is the reference's skip — **two** gates that both jump the same block
/// (`0x50ea87` / `0x50ea8b` → `0x50eb01`, wow-re `camera-shake-law.md` §6): the followed unit
/// (`0x50ea87` / `0x50ea8b` → `0x50eb01`): the followed unit
/// is **swimming** (`[[unit+0x118]+0x40] & 0x200000`), or it is riding a **flying server
/// spline** (`MI = [[unit+0x118]+0xa4]` non-null, not done, Flying bit set) — a taxi flight.
/// The accumulator is zeroed *before* both gates, so a suspended frame yields the offset zero
@@ -221,7 +221,7 @@ impl LiveShake {
/// This record's `(compare key, signed offset)` at `eye`, or `None` when out of cull range.
///
/// The compare key is the **distance-attenuated amplitude** — a per-record quantity that does
/// not swing with the sine — per wow-re's reading of the accumulator's stored slot.
/// not swing with the sine — the stored slot the reference compares at `0x5118a5`.
fn sample(&self, eye: Vec3, now: f32) -> Option<(f32, f32)> {
let d2 = eye.distance_squared(self.pos);
if d2 > CULL_DISTANCE_SQ {
@@ -404,7 +404,7 @@ fn fire_shakes(
/// Charge (1424). That is a read-before-write of a zero-initialised field, not a design — the
/// value it wants is unambiguous, being the same one the sound leg uses a moment later — so we
/// spawn at the kit play's real position and the authored data plays. benilla implements the
/// mechanism, not the quirk (the contract §3, `method.md` step 3); the reference's own numbers are
/// mechanism, not the quirk (`docs/METHOD.md` §3, `docs/METHOD.md` step 3); the reference's own numbers are
/// in `decisions/1849`, and reverting to them would mean deliberately modelling an uninitialised
/// field we do not have.
fn fire_kit_shakes(
@@ -451,14 +451,14 @@ type FollowedUnit = (
/// **Swimming** is its move flags (`[[unit+0x118]+0x40] & 0x200000`).
///
/// **The spline gate** is read through to the LIVE path, exactly as the anim selector's `unify`
/// does ([`crate::creature_anim`], RF-0057 `0x5fd19c`) — the reference re-reads
/// does ([`crate::creature_anim`], `0x5fd19c`) — the reference re-reads
/// `[[unit+0x118]+0xa4]` every frame rather than trusting a stamped flag, and our stored
/// [`MovementState::flying`] is the selector's own derived value, left `false` on the controller's
/// component. The [`Spline`]'s *presence* is the reference's "descriptor non-null **and** not done"
/// (`sample_splines` removes the component the frame the path completes — the `& 0x4` DONE term),
/// and `!grounded` is spline flag `0x200`.
///
/// **`0x200` is the shared fly-*or*-swim spline bit, not "taxi"** (wow-re `swim-transition.md`) —
/// **`0x200` is the shared fly-*or*-swim spline bit, not "taxi"** (`0x616cb0`) —
/// so this arm covers a swimming server path as well as a flight, which is the same reason it
/// sits beside a swim gate at all. Naming it "the taxi arm" (as 1540 did, and as this was first
/// built) over-specifies what the bit means. A **ground** spline — Charge, knockback, a fleeing
@@ -200,7 +200,8 @@ struct QuadProbe {
corners: [Vec2; 4],
/// The NDC depth the corners carry. Read from the projected corners, not assumed constant —
/// the *spread* is reported, because a billboard that is not a constant-depth plane is a
/// different bug wearing the same symptom (wow-re `part-flush-emitter-depth.md` §4).
/// different bug wearing the same symptom (`0x7b2a50`'s plain billboard gives all four
/// corners one depth).
dquad: f32,
dspread: f32,
/// The quad centre's distance to the camera plane, yards — the unit the burial is reported in.
+24 -12
View File
@@ -42,7 +42,9 @@ pub(super) fn seed_ui_fixture(
mut quest: ResMut<crate::ui_quest::QuestGiver>,
mut quest_log: ResMut<crate::ui_quest_log::QuestLog>,
mut loot: ResMut<crate::ui_loot::LootState>,
mut items: ResMut<crate::items::Items>,
// The item store and the object index as one param (the 16-SystemParam ceiling): the item
// objects are entities in the index (2334), and the fixture spawns them like the wire does.
store: (ResMut<crate::items::Items>, ResMut<crate::net::GuidIndex>),
mut names: ResMut<crate::names::NameCache>,
icons: Option<Res<crate::entities::ItemDisplays>>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
@@ -57,6 +59,7 @@ pub(super) fn seed_ui_fixture(
ResMut<crate::loading_screen::LoadingScreen>,
),
) {
let (mut items, mut index) = store;
// A glue-screen capture has no world scenario, and no glue screen opens a UI fixture.
let Some(scenario) = ctx.scenario else {
return;
@@ -72,7 +75,7 @@ pub(super) fn seed_ui_fixture(
// **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
// world, and exit 0. That is the false-negative shape `docs/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`).
@@ -326,7 +329,7 @@ pub(super) fn seed_ui_fixture(
(G_STONE, 94_004, 1, "Hearthstone", DISP_STONE, 1),
(G_BANKBAG, 94_005, 1, "Small Brown Pouch", DISP_STONE, 1),
] {
items.insert_object(guid, obj(entry, stack));
crate::items::spawn_item(&mut commands, &mut index, guid, obj(entry, stack), false);
let mut t = template(name, quality);
t.display_info_id = disp;
if guid == G_BANKBAG {
@@ -338,7 +341,9 @@ pub(super) fn seed_ui_fixture(
// The held bank bag is a real CONTAINER object (its contents stream on the bag item —
// decision 0604): 6 slots, one occupied, so the POPOUT window (container 5) is in the
// shot too — its snug-fit stitch, lit bag button, and own-icon portrait.
items.insert_object(
crate::items::spawn_item(
&mut commands,
&mut index,
G_BANKBAG,
ObjectFields::from_pairs(&[
(3, 94_005),
@@ -346,6 +351,7 @@ pub(super) fn seed_ui_fixture(
(48, 6), // CONTAINER_NUM_SLOTS
(50, G_JERKY as u32), // CONTAINER_SLOT_1
]),
true,
);
// The self player: 4 occupied vault slots, the bank bag, TWO bought bag slots
// (`PLAYER_BYTES_2` byte 2 — bag button 1 owned, 2 bought-but-empty, 3–6 the red
@@ -717,7 +723,7 @@ pub(super) fn seed_ui_fixture(
// Raised through the real registry entry, not a hand-built frame: the text comes from
// the chain's own `INVITATION` GlobalString and the two buttons from ACCEPT/DECLINE,
// so the capture exercises the same Show path a real invite takes.
if let Err(e) = script.run(r#"StaticPopup_Show("PARTY_INVITE", "Thalyn")"#) {
if let Err(e) = script.run(r#"StaticPopup_Show("PARTY_INVITE", "Brisca")"#) {
warn!("capture: ui-partyinvite seed failed to raise the dialog: {e}");
}
}
@@ -765,8 +771,8 @@ pub(super) fn seed_ui_fixture(
..Default::default()
},
);
// The §22 SET block (real Defias Leather shape, 5 members, one equipped): gold
// "(1/5)" header + spacer, cream/gray member ladder, green (2)-bonus vs gray
// The item-SET block (`0x52b650`; real Defias Leather shape, 5 members, one equipped):
// gold "(1/5)" header + spacer, cream/gray member ladder, green (2)-bonus vs gray
// (4)-bonus — the whole block's visual regression instrument.
let mut inv: benilla_ui::script::InventorySlots = Default::default();
inv[4] = Some(benilla_ui::script::InvSlotView {
@@ -911,10 +917,10 @@ pub(super) fn seed_ui_fixture(
let obj = |entry: u32, stack: u32| {
ObjectFields::from_pairs(&[(3, entry), (14, stack)]) // OBJECT_ENTRY, STACK_COUNT
};
items.insert_object(G_CHEST, obj(93_010, 1));
items.insert_object(G_SWORD, obj(93_011, 1));
items.insert_object(G_BOW, obj(93_013, 1));
items.insert_object(G_ARROWS, obj(93_012, 200));
crate::items::spawn_item(&mut commands, &mut index, G_CHEST, obj(93_010, 1), false);
crate::items::spawn_item(&mut commands, &mut index, G_SWORD, obj(93_011, 1), false);
crate::items::spawn_item(&mut commands, &mut index, G_BOW, obj(93_013, 1), false);
crate::items::spawn_item(&mut commands, &mut index, G_ARROWS, obj(93_012, 200), false);
let mut chest = template("Tarnished Chainmail", 1);
chest.display_info_id = DISP_SHIELD;
chest.inventory_type = 5;
@@ -1181,7 +1187,13 @@ pub(super) fn seed_ui_fixture(
crate::net::Guid(PLAYER_GUID),
));
// The equipped main-hand weapon — the auto-attack borrows its icon (decision 0230).
items.insert_object(G_SWORD, ObjectFields::from_pairs(&[(3, 93_011), (14, 1)]));
crate::items::spawn_item(
&mut commands,
&mut index,
G_SWORD,
ObjectFields::from_pairs(&[(3, 93_011), (14, 1)]),
false,
);
let mut sword = template("Militia Shortsword", 2);
sword.display_info_id = DISP_SWORD;
items.insert_template(93_011, Some(sword));
+2 -2
View File
@@ -97,8 +97,8 @@ pub(crate) fn drive_fx_view(
.id();
}
// `WOW_FX_GO`: the GAMEOBJECT lane. A placed trap/door/chest reaches the screen through
// `crate::go_anim`'s §243 state machine, never through the effect pool, so this is the
// only lane that reproduces one. The descriptor carries the three fields the machine
// `crate::go_anim`'s state machine (`0x5f3cb0`), never through the effect pool, so this is
// the only lane that reproduces one. The descriptor carries the three fields the machine
// reads — display, TYPE_ID (the `go_animates` gate) and STATE (the substate) — and
// nothing else, so an unset knob renders exactly what an omitted wire field renders.
// Always seated on the terrain: a GO stands on the ground.
+6 -6
View File
@@ -226,12 +226,12 @@ fn glue_screen() -> Option<GlueScreen> {
///
/// **`WOW_FX_GO=<GameObjectDisplayInfo id>` swaps the subject onto the GAMEOBJECT path** — the
/// third lane, and the one a placed trap/door/chest actually takes: the wire component set a
/// streamed GO gets, so `crate::go_anim`'s §243 state machine — not the effect pool — chooses the
/// sequence. `WOW_FX_GO_STATE` (`GAMEOBJECT_STATE`, default 1 READY) and `WOW_FX_GO_TYPE`
/// (`GAMEOBJECT_TYPE_ID`, default 6 TRAP) select the substate; `benilla-extract goanimscan`
/// *predicts* what that resolves to, and this *shows* it. The lane matters because an absent
/// `GAMEOBJECT_STATE` reads as the wire default `0` = ACTIVE, which on a model with no `Opened`
/// sequence lands somewhere else entirely:
/// streamed GO gets, so `crate::go_anim`'s state machine (`0x5f3cb0`) — not the effect pool —
/// chooses the sequence. `WOW_FX_GO_STATE` (`GAMEOBJECT_STATE`, default 1 READY) and
/// `WOW_FX_GO_TYPE` (`GAMEOBJECT_TYPE_ID`, default 6 TRAP) select the substate;
/// `benilla-extract goanimscan` *predicts* what that resolves to, and this *shows* it. The lane
/// matters because an absent `GAMEOBJECT_STATE` reads as the wire default `0` = ACTIVE, which on a
/// model with no `Opened` sequence lands somewhere else entirely:
///
/// ```text
/// WOW_DATA=<Data> WOW_CAPTURE=fxview WOW_FX_GO=3073 WOW_FX_GO_STATE=1 WOW_FX_AGE=4 \
+63 -19
View File
@@ -14,7 +14,7 @@
//! is the step this probe exists for. The house id is checked to be in `1..=7` because it keys the
//! deposit rate the sell pane quotes, and a zero would silently quote a free listing.
//!
//! ## The auctioneer (live-DB verified this session, `/Users/sam/dev/vmangos-deploy` → `mangos`)
//! ## The auctioneer (live-DB verified against the local vmangos, `mangos`)
//!
//! Auctioneer Fitch, creature entry 8719, spawn guid 12696, map 0 (Stormwind, Trade District),
//! pos `(-8821.53, 659.886, 97.4645)`. Her `creature_template.npc_flags` is **4096** exactly —
@@ -57,8 +57,8 @@
//! WOW_PROBE_AUCTION=1 WOW_NOSOUND=1 WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive \
//! cargo run -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>`, method.md "The local vmangos server"; this worktree is `pool-5`).
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill).
//! Non-combat. An outer grep on `PROBE_AUCTION:` is the whole harness; the probe self-exits (the
//! [`super::probes::ProbeExitPlugin`] pattern) once DONE.
@@ -161,11 +161,20 @@ struct AuctionProbe {
baseline_money: u32,
/// `AUCTION_OWNED_LIST_UPDATE`'s count immediately before `StartAuction` went in.
///
/// Taken THERE and not when step (6) starts, because the STARTED result queues an owner
/// re-query of its own: by the time (6) is reached the event may already have fired, and a
/// baseline taken then would be waiting for a second one that a repeated identical page will
/// never produce.
/// Taken THERE and not when step (6) starts, so that an owner re-query queued by the STARTED
/// result cannot fire the event before the baseline is read — a baseline taken after it would
/// wait for a second event that a repeated identical page will never produce. (Since 2308 that
/// re-query only happens for a list we already hold, and step 6 is what first asks for this
/// one, so the ordering is belt and braces rather than load-bearing.)
owned_event_baseline: i64,
/// How many red `UI_ERROR_MESSAGE` lines the run had already raised when the auction window
/// opened — the login environment's own, not the arc's.
///
/// Step (6d) asserts the arc raised **no** red line, and it used to read the whole tally: on a
/// `probeN` account that is never zero, because the preflight's `GM mode is ON` and
/// `GOD mode is ON` banners (0679) are red lines too, and they land before the greeting. The
/// assertion was therefore FAILING on every clean run, which is worse than not making it.
red_baseline: i64,
passes: u32,
fails: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
@@ -287,6 +296,13 @@ fn last_error(script: &UiScript) -> String {
///
/// Compared against `getglobal("ERR_AUCTION_STARTED")` rather than against English: the string is
/// the player's own, and this file must not carry a copy of it.
/// Red `UI_ERROR_MESSAGE` lines raised so far, as the hook's own array counts them.
fn red_count(script: &UiScript) -> i64 {
script
.eval::<i64>("return table.getn(ProbeAuctionErrors)")
.unwrap_or(-1)
}
fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
let said = script
.eval::<i64>(
@@ -298,9 +314,7 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
return 0",
)
.unwrap_or(-2);
let reds = script
.eval::<i64>("return table.getn(ProbeAuctionErrors)")
.unwrap_or(-1);
let reds = red_count(script) - probe.red_baseline;
match (said, reds) {
(1, 0) => {
info!(
@@ -317,13 +331,16 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
probe.fails += 1;
}
(said, reds) => {
// The reds are listed from the baseline on, for the same reason `reds` counts from
// there: the login environment's own banners are not this arc's output.
let lines = script
.eval::<String>(
"local t = {} \
.eval::<String>(&format!(
"local t = {{}} \
for i = 1, table.getn(ProbeAuctionChat) do table.insert(t, ProbeAuctionChat[i]) end \
for i = 1, table.getn(ProbeAuctionErrors) do table.insert(t, \"RED:\" .. ProbeAuctionErrors[i]) end \
for i = {} + 1, table.getn(ProbeAuctionErrors) do table.insert(t, \"RED:\" .. ProbeAuctionErrors[i]) end \
return table.concat(t, \" | \")",
)
probe.red_baseline.max(0)
))
.unwrap_or_default();
error!(
"PROBE_AUCTION: FAIL (6d chat) — wanted the STARTED line in chat and zero red \
@@ -342,8 +359,15 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
/// coin. Derived from `probe.min_bid` rather than hardcoded, because the listing price is computed
/// from the item's own vendor value and a fixed expectation would be a lie the day the item changes.
///
/// `IsShown`, not `IsVisible`: the Auctions pane is behind the Browse tab at this point in the run
/// and the rows are painted either way — visibility would test the tab, not the coins.
/// `IsShown`, not `IsVisible`: the coin buttons' own shown-ness is the subject, not whatever the
/// window is doing around them.
///
/// The pane is the *front* tab by the time this runs, and that is load-bearing rather than
/// incidental: the row is painted by `AuctionFrameAuctions_Update`, which cannot complete before
/// the tab's `OnShow` has given it a `page` (decision 2308). This check read the player's purse
/// out of the row's money frame for as long as the probe asked for the owned list without opening
/// the tab — the frame was still on its `MoneyTypeInfo["PLAYER"]` default because nothing had ever
/// repainted it.
fn owner_money_check(script: &UiScript, probe: &mut AuctionProbe) {
let (gold, silver, copper) = (
probe.min_bid / 10_000,
@@ -601,6 +625,9 @@ fn auction_probe(
auction.auctioneer.unwrap_or(0)
);
probe.passes += 1;
// Everything red before this instant is the login environment (the GM/GOD
// banners); the arc's own tally starts here.
probe.red_baseline = red_count(&script);
} else {
error!(
"PROBE_AUCTION: FAIL (2 greet) — the session opened, but on {:?} (we \
@@ -1151,10 +1178,27 @@ fn auction_probe(
probe.phase = cancel_at(now);
return;
}
// Re-ask on a cadence: the server drops a second in-flight list request silently, and
// the STARTED result has already queued a refresh of its own.
// Re-ask on a cadence: the server drops a second in-flight list request silently.
//
// **The FIRST ask is the tab click, not the raw binding** (decision 2308). Every
// caller of `GetOwnerAuctionItems` in the stock files lives inside the Auctions pane —
// its `OnShow` and its two page turners — and the `OnShow` is also the one place that
// assigns `AuctionFrameAuctions.page`. Calling the binding cold puts the window in a
// state no click can reach, and the reply's `AUCTION_OWNED_LIST_UPDATE` then repaints
// a tab whose `page` is still nil: this probe raised
// `Blizzard_AuctionUI.lua:837: attempt to perform arithmetic on field 'page'` twice a
// run doing exactly that, at WARN, where nothing was looking. Clicking the tab is both
// the faithful drive ("exactly as a click would", this file's header) and what makes
// step (6c) a real assertion instead of a permanently red one.
if last_ask == 0.0 || now - last_ask > OWNER_REASK_SECS {
crate::ui_script::run_or_warn(&script, "GetOwnerAuctionItems()");
let ask = if last_ask == 0.0 {
// The tab's own OnShow issues the query, once per window session
// (`AuctionFrame.gotAuctions`), and sets the page the repaint needs.
"AuctionFrameTab_OnClick(3)"
} else {
"GetOwnerAuctionItems()"
};
crate::ui_script::run_or_warn(&script, ask);
probe.phase = Phase::OwnerList {
since,
last_ask: now,
+4 -3
View File
@@ -9,7 +9,7 @@
//! window is first-party, no Lua click surface to drive); the live Lua VM is only touched for the
//! bonus refusal step's `UI_ERROR_MESSAGE` observation, the mail probe's own idiom.
//!
//! ## The banker (live-DB verified this session, `/Users/sam/dev/vmangos-deploy` → `characters`/
//! ## The banker (live-DB verified against the local vmangos, `characters`/
//! `mangos` DBs)
//!
//! Soleil Stonemantle, creature entry 5099, spawn guid 12629, map 0 (Ironforge, The Vault), pos
@@ -23,7 +23,7 @@
//!
//! vmangos `Chat.cpp`'s `modifyCommandTable` pins `.modify money` at `SEC_BASIC_ADMIN` (4,
//! `Common.h` `AccountTypes`) — one level ABOVE `SEC_GAMEMASTER` (3), the level every probe account
//! is provisioned at (method.md). So `.modify money` is refused server-side for a probe login,
//! is provisioned at (docs/METHOD.md). So `.modify money` is refused server-side for a probe login,
//! exactly the same floor [`super::probe_taxi`]'s module doc already found for the taxi fare. This
//! probe never sends it: step (e) reads the character's live `money` field instead (DB-verified
//! this session: `Probeone` carries 100000 copper, `bank_bag_slots` 0 — comfortably funds the
@@ -36,7 +36,8 @@
//! WOW_DATA=WoW/Data WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
//! WOW_PROBE_BANK=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — this worktree is `pool-1` → `probe1`/`pprobe1`/`Probeone`).
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill).
//! Non-combat; GM mode is left exactly as found. An outer `timeout` + grep on `PROBE_BANK:` is the
//! whole harness; the probe self-exits ([`super::probes::ProbeExitPlugin`]'s pattern) once DONE.
+2 -3
View File
@@ -93,8 +93,7 @@
//! assignment reads off it (0649, 0679).
//!
//! Non-combat — the probe never attacks and never stands anywhere contested; with one player in
//! the instance there is nobody to fight. Pair with the SLOT-KEYED probe identity
//! (`method.md`, "The local vmangos server").
//! the instance there is nobody to fight. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill).
use bevy::ecs::system::NonSendMut;
use bevy::prelude::*;
@@ -263,7 +262,7 @@ const GHOST_SAMPLES: u32 = 11;
const FLAG_SAMPLES: u32 = 5;
/// `SPIRITGUIDE` — `UNIT_NPC_FLAGS` bit 6, the flag the reference's area-spirit-healer acquire
/// scan keys on (wow-re `interact-dead-fork-and-npc-service-ladder.md` §C row 6).
/// scan `0x4924c0` keys on.
const NPC_FLAG_SPIRITGUIDE: u32 = 1 << 6;
/// The **event tap** — a Lua frame the probe installs on entry that records every battleground
@@ -30,8 +30,7 @@
//! 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
//! Non-combat. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill), 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::*;
@@ -15,7 +15,7 @@
//! visible against a real server sending real bytes, which is what this probe puts in front of
//! them.
//!
//! ## The innkeeper (live-DB verified this session, `/Users/sam/dev/vmangos-deploy` → `mangos` DB)
//! ## The innkeeper (live-DB verified against the local vmangos, `mangos` DB)
//!
//! Innkeeper Keldamyr — `creature_template.entry = 6736`, spawn `creature.guid = 46343`, **map 1**
//! (Teldrassil, Dolanaar), position `(9802.21, 982.608, 1313.98)`,
@@ -77,8 +77,8 @@
//! WOW_DATA=WoW/Data WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
//! WOW_PROBE_BINDER=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — this worktree is `pool-1` → `probe1`/`pprobe1`/`Probeone`;
//! method.md "The local vmangos server"). Non-combat, and GM mode is left exactly as found. An
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill). Non-combat, and GM mode is left exactly as found. An
//! outer `timeout` + grep on `PROBE_BINDER:` is the whole harness; the probe self-exits once DONE.
//!
//! Every step SKIPs with a note rather than FAILing for an environmental problem (the NPC never
@@ -224,7 +224,7 @@ fn last_event(script: &UiScript) -> String {
/// The `CHAT_MSG_SYSTEM` lines seen since the hook went in, newest last — step 6's second
/// observation. `SMSG_PLAYERBOUND` prints `ERR_DEATHBIND_SUCCESS_S` here (`0x5e3d3f` →
/// `DisplayError(0x138)`, chat type 238 = CHAT_MSG_SYSTEM; wow-re, decision 1335).
/// `DisplayError(0x138)`, chat type 238 = CHAT_MSG_SYSTEM; decision 1335).
fn system_lines(script: &UiScript) -> Vec<String> {
script
.eval::<Vec<String>>("return ProbeBinderSystemLines or {}")
+10 -11
View File
@@ -1,10 +1,9 @@
//! The world-book live probe (`WOW_PROBE_BOOK=1`) — B240's instrument: what does having the item-
//! text reader open actually cost per frame, on the real object the report names?
//!
//! Goudy, 2026-08-09 (`#bugs` `1535810645956632686`): *"page text with html text absolutely
//! annihilates performance"* — ~50% fps drop (62 → 36 fps, 16.0 → 28.0 ms) while the *Alliance
//! Military Ranks* plaque's reader is up, recovering the moment it closes. That is a **frame-cost
//! A/B**, and eyeballing an fps counter is exactly the way not to settle one (method.md's
//! The symptom: a ~50% fps drop (62 → 36 fps, 16.0 → 28.0 ms) while the *Alliance Military Ranks*
//! plaque's HTML page is up in the reader, recovering the moment it closes. That is a **frame-cost
//! A/B**, and eyeballing an fps counter is exactly the way not to settle one (docs/METHOD.md's
//! "timing and feel are measured, never eyeballed"). So this probe teleports to the plaque, samples
//! the UI pass's own per-phase meter ([`crate::ui_script::UiFrameCost`]) with the reader CLOSED,
//! opens it on the real route, samples again, and prints the two side by side — including how many
@@ -13,7 +12,7 @@
//!
//! The object: `GameObject` 3011 (`gameobject_template` entry 2857, `GAMEOBJECT_TYPE_TEXT` = 9)
//! in Stormwind's Old Town, whose `data[0]` is `page_text` 2676 — a 647-byte HTML body. The
//! `.go xyz` below is Goudy's own reported position (his debug panel: `-8760.2 402.3 103.9`).
//! `.go xyz` below is the reported standing position, off the debug panel: `-8760.2 402.3 103.9`.
//!
//! ## The run recipe
//!
@@ -21,8 +20,8 @@
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_BOOK=1 cargo run -q -p benilla --release
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, method.md
//! "The local vmangos server"). An outer `timeout` + a grep on `PROBE_BOOK:` is the whole harness;
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill). An outer `timeout` + a grep on `PROBE_BOOK:` is the whole harness;
//! the probe self-exits ([`super::probes::ProbeExitPlugin`]'s pattern) once DONE.
use bevy::prelude::*;
@@ -36,8 +35,8 @@ use crate::player::Player;
use crate::ui_item_text::ItemTextOpen;
use crate::ui_script::{UiCostWanted, UiFrameCost};
/// Goudy's own reported standing position beside the plaque (his debug panel, screenshot
/// `1535810480424222760-1`).
/// The reporter's own standing position beside the plaque (their debug panel, in the report's
/// screenshot).
const PLAQUE_AT: [f32; 3] = [-8760.2, 402.3, 103.9];
/// `GAMEOBJECT_TYPE_TEXT` — the strategy type a book/plaque carries (decision 1105).
const GO_TYPE_TEXT: i32 = 9;
@@ -131,7 +130,7 @@ fn reader_state(script: &UiScript) -> (bool, i64) {
/// Every string the UI currently draws — the render list is the honest place to ask what the page
/// looks like, and the only place since `ItemTextPageText` became a `SimpleHTML` (5875's has no
/// `GetText`; wow-re `simplehtml-markup-engine.md` §5.1).
/// `GetText` in its method table `0x87ba80`).
fn drawn_strings(script: &UiScript) -> Vec<String> {
use benilla_ui::script::QuadContent;
script
@@ -146,7 +145,7 @@ fn drawn_strings(script: &UiScript) -> Vec<String> {
}
/// **B240's render half, checked where it was reported.** The page body is HTML; if the parse ever
/// falls back, the reader draws the markup itself — which is what Goudy photographed. So: no drawn
/// falls back, the reader draws the markup itself — the reported symptom. So: no drawn
/// string may contain a tag, and the page's own lines must each be there as their own block.
fn report_render(script: &UiScript) -> u32 {
let drawn = drawn_strings(script);
@@ -4,7 +4,7 @@
//! started-`Casting` half of the cancel's inflight union), then 2 s into the bar inject a real
//! `W` press into `ButtonInput<KeyCode>` — the ACTUAL controller path, so the probe drives the
//! same move-start edge a player's key does (controller → `LocalMoveStart` →
//! `ui_cast::local_self_cancel` → `CMSG_CANCEL_CAST`).
//! `spell::local_self_cancel` → `CMSG_CANCEL_CAST`).
//!
//! The verdict is read off the run log with `WOW_CAST_TRACE=1`: the `LOCAL self-cancel` line
//! must land beside the `SEND move StartForward` line — frames, not a server round trip, after
@@ -12,10 +12,9 @@
//! anything (the reap already emptied the `Casting` key it tests). The probe also logs every
//! cast-bar phase transition ([`bar_timeline`]) so the red bar's hold/burst/fade durations are
//! measured off the timestamps, not eyeballed. Non-combat, safe unattended
//! (method.md's rule bans unattended *combat* probes); a hearth that completes because the
//! (docs/METHOD.md's rule bans unattended *combat* probes); a hearth that completes because the
//! cancel FAILED just ports the probe character home — visible in the log as the missing cancel line. Pair with
//! the slot-keyed probe identity (`WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<N-spelled>`,
//! method.md) + `WOW_CAST_TRACE=1 WOW_PROBE_EXIT_AT=<s>`.
//! the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill) + `WOW_CAST_TRACE=1 WOW_PROBE_EXIT_AT=<s>`.
use bevy::prelude::*;
@@ -8,7 +8,8 @@
//! the `SMSG_CHAR_CREATE` result byte, and — unless `WOW_PROBE_CHARCREATE_KEEP=1` — deletes the
//! character it just made ([`CharRequest::Delete`]) so the account isn't littered, logging that
//! result too. Run it **without** `WOW_CHAR` (which would auto-enter the world and leave select),
//! against the slot-keyed probe account (`WOW_USER=probeN WOW_PASS=pprobeN`, N = your pool slot — method.md "The local vmangos server"); creating/deleting characters is a
//! against the checkout's own probe account (`.probe-identity`, or `WOW_USER`/`WOW_PASS` — the
//! `probe` skill); creating/deleting characters is a
//! non-combat operation, safe to run headlessly.
use benilla_protocol::{messages, CharAction, CharCreateReq};
+28 -15
View File
@@ -16,7 +16,7 @@
//! fork arm whose whole evidence is "no other arm accepts a charter". Those are only visible
//! against real bytes.
//!
//! ## The registrar (live-DB verified this session, `/Users/sam/dev/vmangos-deploy` → `mangos` DB)
//! ## The registrar (live-DB verified against the local vmangos, `mangos` DB)
//!
//! Aldwin Laughlin, the Stormwind guild registrar — `creature_template.entry = 4974`, spawn
//! `creature.guid = 79681`, **map 0**, position `(-8885.25, 614.395, 95.2576)`,
@@ -85,10 +85,9 @@
//! account to sign, and #5 (`ERR_GUILD_FOUNDER_S` on a successful turn-in) needs nine of them —
//! neither is reachable from one client, so the whole sign/offer/turn-in half of the family
//! (`SignPetition`, `OfferPetition`, `TurnInGuildCharter`, `MSG_PETITION_DECLINE`) still has no
//! live coverage after this probe. #3 (the two closes send nothing) is *superseded*: the wow-re
//! carve that landed alongside this file finds that closing a charter you do NOT own can send
//! `MSG_PETITION_DECLINE`, so this probe deliberately asserts nothing about either close rather
//! than pinning a claim that is being rewritten.
//! live coverage after this probe. #3 (the two closes send nothing) is *superseded*: closing a
//! charter you do NOT own can send `MSG_PETITION_DECLINE` (`0x4f3f60`), so this probe deliberately
//! asserts nothing about either close rather than pinning a claim that is being rewritten.
//!
//! ## The run recipe
//!
@@ -96,8 +95,7 @@
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CHARTER=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — this worktree is `pool-0` → `probe0`/`pprobe0`/`Probezero`;
//! method.md "The local vmangos server". **Never the default `one` account** — a login on it kicks
//! (the checkout's probe identity, the `probe` skill. **Never a player's account** — a login on it kicks
//! the director's live session.) `WOW_NOSOUND=1` because an unattended probe must not play zone
//! music into the director's room; `caffeinate -dis` is **not** needed for a run this short — the
//! whole sequence is a handful of round trips and finishes in well under a minute.
@@ -117,7 +115,9 @@ use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::items::Items;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::net::{
ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, Objects, SelfPlayer,
};
use crate::player::Player;
use crate::target::cursor_mode::npc_flags;
use crate::ui_gossip::GossipState;
@@ -160,7 +160,7 @@ const REQUIRED_SIGNATURES: i64 = 9;
const FRESH_SIGNATURES: i64 = 0;
/// Copper handed to the probe body up front so the buy can never fail for funds. `.modify money`
/// is `SEC_BASIC_ADMIN` (4) in vmangos's `Chat.cpp` command table and every `probeN` account is
/// gmlevel **6** (method.md, decision 0651), so it lands; with no selection it targets the sender
/// gmlevel **6** (docs/METHOD.md, decision 0651), so it lands; with no selection it targets the sender
/// (`ChatHandler::GetSelectedPlayer`, `Chat.cpp:2601-2612`), which is why it is sent before the
/// probe touches an NPC.
const FUND_COPPER: u32 = 100_000;
@@ -458,6 +458,7 @@ fn charter_probe(
mut probe: ResMut<CharterProbe>,
gossip: Res<GossipState>,
registrar: Res<GuildRegistrarState>,
objects: Objects,
items: Res<Items>,
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
@@ -797,7 +798,12 @@ fn charter_probe(
};
return;
}
let found = find_item(&store.0, &items, CHARTER_ITEM_ENTRY, ItemSearch::default());
let found = find_item(
&store.0,
&objects,
CHARTER_ITEM_ENTRY,
ItemSearch::default(),
);
// The template has to have landed too, and it is folded into the SAME `Option` as the
// item rather than short-circuiting on its own: the click dispatcher's charter arm is
// a **template flag** test (`ITEM_FLAG_CHARTER`), so a click made before the answer
@@ -1168,9 +1174,12 @@ fn charter_probe(
// destroys whatever whole stack sits at the addressed position, so a stale pair
// would destroy the wrong item; nothing is expected to move a charter, and that is
// exactly the kind of expectation a destructive send must not rest on.
let Some((bag_index, slot, _)) =
find_item(&store.0, &items, CHARTER_ITEM_ENTRY, ItemSearch::default())
else {
let Some((bag_index, slot, _)) = find_item(
&store.0,
&objects,
CHARTER_ITEM_ENTRY,
ItemSearch::default(),
) else {
probe.pass(
9,
"cleanup",
@@ -1197,8 +1206,12 @@ fn charter_probe(
};
return;
}
let still_there =
find_item(&store.0, &items, CHARTER_ITEM_ENTRY, ItemSearch::default());
let still_there = find_item(
&store.0,
&objects,
CHARTER_ITEM_ENTRY,
ItemSearch::default(),
);
if still_there.is_none() {
probe.pass(
9,
@@ -1,14 +1,14 @@
//! The chest live probe (`WOW_PROBE_CHEST=1`) — **B84's instrument**: does the player kneel at an
//! open chest?
//!
//! Goudy, 2026-07-26 (`#bugs` `1530708187173359646`): *"No looting/opening animation when using a
//! chest"*, with a benilla/reference pair at the same Mirror Lake Orchard chest — the reference
//! The symptom: no looting animation when opening a chest. At the same Mirror Lake Orchard chest,
//! benilla and the reference side by side show the reference
//! gnome kneeling over the open loot window, ours standing bolt upright. Decision 0515 had shipped
//! the kneel keyed to the `CMSG_LOOT` send alone and recorded a chest's silence as *correct*; 1471
//! is the correction (the real client also arms the latch in `OnLootResponse 0x5eb900`, and a chest
//! never sends `CMSG_LOOT` at all).
//!
//! **A kneel is a number here, not a picture** (`method.md` step 4: timing and pose questions are
//! **A kneel is a number here, not a picture** (`docs/METHOD.md` step 4: timing and pose questions are
//! settled with instruments). The probe reads the self unit's own
//! [`AnimDriver::active_anim`][crate::creature_anim::AnimDriver::active_anim] — the id the base
//! selector actually landed on — so the verdict is `50` (`Loot`) or it is not, with no screenshot
@@ -38,8 +38,8 @@
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CHEST=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_CHEST=<x>,<y>,<z>[,<map>]` aims it at a different
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill). `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]` aims it at a different
//! object; the default is a live `Battered Chest` spawn (`gameobject.guid` 26756, template 2843) in
//! the Mirror Lake stretch of Elwynn, the same corner of the world the report came from. A chest is
//! a respawning spawn point: if someone emptied that one it will be missing for its respawn timer,
+8 -6
View File
@@ -4,7 +4,7 @@
//! The director, 2026-08-22: *"Clams don't open anymore. I right click them and they turn gray
//! correctly but no loot window opens ever."* Both halves of that sentence are readings this probe
//! takes separately, because they come from two different pre-send writes in the same emitter
//! (`0x5edc80`, wow-re `loot-anim-leg.md` §5 / `inventory-change-failure-display.md` §8):
//! (`0x5edc80`):
//!
//! - the **grey lock** ([`PendingItemOps`], `0x4953e0` at `0x5edcd9`) — the half that kept working;
//! - the **loot latch** ([`LootLatch`], `0x5edcc0`) — the half decision 1477 left unmodelled, on
@@ -34,8 +34,8 @@
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CLAM=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_CLAM=<entry>` aims it at a different openable template;
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill). `WOW_PROBE_CLAM=<entry>` aims it at a different openable template;
//! the default is a Small Barnacled Clam (7973 — `Flags` LOOTABLE with `LockID = 0`, so it is
//! openable the moment it exists: no key, no lockpicking). The probe `.additem`s its own copy and
//! subtracts it again on the way out, so it leaves the character as it found it.
@@ -52,7 +52,7 @@ use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::items::Items;
use crate::net::{ChatKind, ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{ChatKind, ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::pending_item_ops::PendingItemOps;
use crate::ui_loot::{LootConfig, LootLatch, LootState};
@@ -184,6 +184,7 @@ fn window_open(script: &UiScript, loot: &LootState) -> bool {
fn find_in_backpack(
store: &ObjectStore,
entry: u32,
objects: &Objects,
items: &Items,
net: &NetCommands,
) -> Option<(u64, u32)> {
@@ -195,7 +196,7 @@ fn find_in_backpack(
.filter(|g| *g != 0)
.map(|g| (i, g))
})
.find(|(_, guid)| items.object(*guid).and_then(|f| f.object_entry()) == Some(entry))?;
.find(|(_, guid)| objects.object(*guid).and_then(|f| f.object_entry()) == Some(entry))?;
// `template` asks the server once on a miss and answers `None` until the reply lands, which is
// exactly the "not stocked yet" state this returns.
items
@@ -209,6 +210,7 @@ fn clam_probe(
mut probe: ResMut<ClamProbe>,
script: Option<NonSendMut<UiScript>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
objects: Objects,
items: Res<Items>,
mut cfg: ResMut<LootConfig>,
loot: Res<LootState>,
@@ -240,7 +242,7 @@ fn clam_probe(
probe.phase = Phase::Stocking { sent_at: now };
}
Phase::Stocking { sent_at } => {
if let Some((guid, slot)) = find_in_backpack(store, entry, &items, &net) {
if let Some((guid, slot)) = find_in_backpack(store, entry, &objects, &items, &net) {
info!(
"PROBE_CLAM: item {entry} is guid {guid:#x} in backpack slot {slot} — \
sampling {CONTROL_FRAMES} control frames with it UNCLICKED"
@@ -4,9 +4,8 @@
//! report the seam: aboard → map flip (TRANSFER_PENDING / NEW_WORLD riding branch, logged by
//! the net layer) → still riding → arrived docked on the far continent. Every phase edge prints
//! a `PROBE crossing:` line, so an outer `timeout`d run + grep is the whole harness. Non-combat.
//! Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>` for a `pool-N` worktree — method.md "The local vmangos server";
//! a shared account gets kicked by parallel sessions mid-ride).
//! Pair with the checkout's own probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR
//! — the `probe` skill; a shared account gets kicked by parallel sessions mid-ride).
use bevy::prelude::*;
+1 -1
View File
@@ -60,7 +60,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
name: "WOW_PROBE_EXIT_AT",
purpose: "<secs> — exit the app after N wall seconds; bounds any scripted live probe's lifetime",
// Fires on `ProbeClock` at `<secs>`, and it is the one probe variable a trace-only run
// sets on its own (`WOW_MOVE_TRACE`/`WOW_STREAM_TRACE` legs, method.md) — 0794's
// sets on its own (`WOW_MOVE_TRACE`/`WOW_STREAM_TRACE` legs, docs/METHOD.md) — 0794's
// throttled camera leg was exactly such a run.
wall_clock: true,
},
@@ -93,8 +93,8 @@
//! WOW_NOSOUND=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
//! WOW_PROBE_GMTICKET=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — this worktree is `pool-3` → `probe3`/`pprobe3`/`Probethree`;
//! method.md "The local vmangos server"). Non-combat; the probe never drives the body and sends no
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill). Non-combat; the probe never drives the body and sends no
//! GM command, so GM mode and position are left exactly as found — but note that the body is not
//! necessarily *still* when a run starts (see [`REST_EPS`]). An outer `timeout` + grep on
//! `PROBE_GMTICKET:` is the whole harness; the probe self-exits once DONE. Nothing here is a
@@ -163,11 +163,11 @@ const REST_TIMEOUT_SECS: f64 = 20.0;
/// the packet, before the db-expect line stops being trustworthy (yd, WoW space).
const STAMP_DRIFT_EPS: f32 = 0.05;
/// The operator's half of step 4, printed verbatim so it can be pasted.
const DB_QUERY: &str = "cd /Users/sam/dev/vmangos-deploy && docker compose exec -T database \
mariadb -umangos -pmangos characters -e \"SELECT ticket_id, name, \
ticket_type, map, position_x, position_y, position_z, closed_by, message \
FROM gm_tickets ORDER BY ticket_id DESC LIMIT 1;\"";
/// The operator's half of step 4: the row to read back off the server's `characters` database,
/// printed verbatim so it can be pasted into whatever reaches that database.
const DB_QUERY: &str = "SELECT ticket_id, name, ticket_type, map, position_x, position_y, \
position_z, closed_by, message FROM gm_tickets ORDER BY ticket_id DESC \
LIMIT 1;";
pub(crate) struct ProbeGmTicketPlugin;
@@ -563,7 +563,8 @@ fn gm_ticket_probe(
warn!(
"PROBE_GMTICKET: SKIP (4 db) — operator step, by design: the client never \
reads map/position back, so only the server's own row proves they landed. \
After the run: {DB_QUERY} — and compare against the db-expect line(s)."
After the run, against the server's `characters` database: {DB_QUERY} — \
and compare against the db-expect line(s)."
);
let text = unique_text(" edited");
if !run_or_skip(
@@ -43,8 +43,8 @@
//! 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
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill). `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
@@ -90,7 +90,7 @@ const NPC_FLAG_QUESTGIVER: u32 = 0x2;
/// `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).
/// `DialogStatus` ids the verdict is written in (the keys of the status map `0x80c454`).
const STATUS_UNAVAILABLE: u32 = 1;
const STATUS_AVAILABLE: u32 = 5;
@@ -319,9 +319,9 @@ fn goquest_probe(
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.
// from a sweep (`0x5eb159`, `0x5eb456`), 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 {
@@ -10,8 +10,7 @@
//! — distance, view radius, and which of the two draws it takes — so the run says *what would be
//! on screen*, not merely that a packet arrived.
//!
//! 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
//! Non-combat. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill), and `WOW_NOSOUND=1` when
//! it runs unattended. One `timeout`'d run plus a grep for `PROBE guardpoi:` is the whole harness.
use bevy::prelude::*;
+4 -5
View File
@@ -5,7 +5,7 @@
//! `PROBE_MAIL: DONE pass=<n> fail=<m>` summary. Modeled closely on [`super::probe_taxi`] (same
//! phase-machine shape, same trace style, same self-terminating exit).
//!
//! ## The GM `.send` syntax (verified `/Users/sam/wre/vmangos-src`, `Chat/Chat.cpp` +
//! ## The GM `.send` syntax (verified against the vmangos source, `Chat/Chat.cpp` +
//! `Commands/MiscCommands.cpp`)
//!
//! `sendCommandTable[]` (Chat.cpp ~l.931): `.send mail <name> "subject" "text"` is
@@ -39,9 +39,8 @@
//! WOW_DATA=<vanilla Data dir> WOW_USER=probe2 WOW_PASS=pprobe2 WOW_CHAR=Probetwo \
//! WOW_PROBE_MAIL=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `WOW_USER=probeN WOW_PASS=pprobeN
//! WOW_CHAR=Probe<N-spelled>`, method.md "The local vmangos server"; this worktree is `pool-2` →
//! `probe2`/`pprobe2`/`Probetwo`). `WOW_CHAR` doubles as the probe's own mail-send target — read
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill). `WOW_CHAR` doubles as the probe's own mail-send target — read
//! once at world-enter, never hardcoded. An outer `timeout` + grep on `PROBE_MAIL:` is the whole
//! harness; the probe self-exits (the [`super::probes::ProbeExitPlugin`] pattern) once DONE.
@@ -359,7 +358,7 @@ fn mail_probe(
let Some(idx) = index_of(&mail, letter_id) else {
return; // shouldn't happen the same frame we just found it — re-poll
};
// CheckInbox() called twice, idempotently (wow-re §5's 60s client-side throttle,
// CheckInbox() called twice, idempotently (`0x4aeab0`'s 60s client-side throttle,
// decision 0548 §2/0544) — proves a rapid re-call is a no-op, not a packet storm.
if let Err(e) = script.run(&format!("CheckInbox() CheckInbox() GetInboxText({idx})")) {
error!("PROBE_MAIL: FAIL (b) — GetInboxText({idx}) errored: {e}");
@@ -2,10 +2,10 @@
//! once in-world, periodically fire the attack-nearest core ([`AttackNearestRequest`] — the
//! action-bar attack's own no-selection path), so the probe character fights whatever is closest
//! while the `benilla_assets::trace` sink (`WOW_MOVE_TRACE=<path>`) records the swing→impact→spawn combat-text
//! timeline for pair-birth analysis. Pair with the slot-keyed probe identity (method.md) + an outer
//! timeline for pair-birth analysis. Pair with the checkout's probe identity (the `probe` skill) + an outer
//! `timeout`.
//!
//! **Do NOT run this unattended** (director's rule, 2026-07-14, `method.md` "The local vmangos
//! **Do NOT run this unattended** (director's rule, 2026-07-14, `docs/METHOD.md` "The local vmangos
//! server"): the probe fights back-to-back with no health awareness and the character DIES (Tri
//! did). Combat runs are director-assisted — ask, or hand an eyeball script.
@@ -4,14 +4,14 @@
//!
//! ## 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 unit tests in `crate::ui_models` pin the leg's arithmetic — the client's diagonal-FOV matrix
//! (`0x5c3cc0`) against its 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
//! The measurement is numeric, not visual (docs/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:
@@ -19,11 +19,10 @@
//! - `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.
//! - `facing` — `SetFacing(1.0)` must not move a pixel **either**: 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. The rule that only `SetFacing` shows is scoped to
//! `<PlayerModel>`'s frozen camera (`0x7acf10`).
//! - `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
@@ -38,7 +37,7 @@
//! 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`
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)). `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;
@@ -56,8 +55,8 @@ use crate::ui_models::{TilePerspectiveCamera, TileRoot, UiModelTiles};
/// 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`).
/// The pane's size in FrameXML units — the pet pane's own `318×224`, whose aspect is a worked
/// example of the projection `0x5c3cc0` (`θ = 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
@@ -192,7 +191,7 @@ fn read_leg(
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).
// platform config dir (docs/METHOD.md'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;
@@ -4,9 +4,9 @@
//! leader/loot management, pings — solo, with the probe account as the other member. It also
//! accepts **duel** challenges (decision 0637), which is what makes the duel arc's two-client
//! questions answerable at all: whether the opponent turns hostile, whether the arbiter flag
//! plants. Non-combat (the unattended-combat ban, `method.md` "The local vmangos server", stands
//! plants. Non-combat (the unattended-combat ban, `docs/METHOD.md` "The local vmangos server", stands
//! untouched: this probe answers a challenge but never swings, and a duel nobody strikes in
//! simply times out). Pair with the slot-keyed probe identity (`WOW_USER=probeN …`, method.md).
//! simply times out). Pair with the checkout's probe identity (the `probe` skill).
use bevy::prelude::*;
@@ -6,17 +6,17 @@
//! `player::controller`, gated on `mouselook` (a held right button or MOVEANDSTEER) *and* a moving
//! OS cursor inside the viewport. An unfocused probe window has neither. So the whole observed-swim
//! lane — the wire's pitch tail, the dead-reckon's pitched travel basis, and the body-pitch render
//! law (decision 0464 TU-A) — was **unreachable from any script**: a benilla client could be made
//! to swim, but only ever level. The tilt shipped in July 2026 with no test, no trace field and no
//! way to drive it, which is why "do other swimmers tilt?" had no answer short of the director's
//! eye, and why a two-client probe of it could not be written at all.
//! law (`0x60a110`, decision 0464) — was **unreachable from any script**: a benilla client could be
//! made to swim, but only ever level. The tilt shipped in July 2026 with no test, no trace field
//! and no way to drive it, which is why "do other swimmers tilt?" had no answer short of the
//! director's eye, and why a two-client probe of it could not be written at all.
//!
//! Format: `WOW_PROBE_PITCH="<deg>@<start_s>[:<deg_per_sec>][;…]"` — `deg` is nose-**up** positive,
//! matching `Player::mover_pitch` and the wire's own sign. `"-30@22;30@40"` aims 30° down at
//! twenty-two seconds and 30° up at forty; `"-45@20:9"` starts level-ish and sweeps at 9°/s from
//! twenty seconds on, which is what a slow real dive looks like on the wire. The last entry whose
//! start has passed wins, and it is re-asserted **every frame** — the reference holds an unsteered
//! pitch rather than levelling it (TU-B(c)), so a one-shot write would be indistinguishable from a
//! pitch rather than levelling it, so a one-shot write would be indistinguishable from a
//! held one; re-asserting is what makes the two separable when only one of them is under test.
//!
//! It writes [`Player::aim_pitch`] — the field the reference's `SetPitch` (`0x7c6f70`) writes,
+37 -36
View File
@@ -1,4 +1,4 @@
//! The probe **rig** (`WOW_RIG="<spec>"`, decision 0651) — one command that puts this slot's probe
//! The probe **rig** (`WOW_RIG="<spec>"`, decision 0651) — one command that puts this checkout's probe
//! account into a chosen **body** and hands the session a world that is ready to test.
//!
//! ## Why it exists
@@ -17,7 +17,7 @@
//! ```text
//! WOW_RIG="tauren druid 60 gear:heal-preraid-bis spec:heal-preraid-bis at:ThunderBluff"
//! WOW_RIG="gnome mage 39 gear:dps-39-twink gm:off"
//! WOW_RIG="60 gear:dps-preraid-bis" # the slot's own Probe<N>, no new character
//! WOW_RIG="60 gear:dps-preraid-bis" # the account's own probe body, no new character
//! WOW_RIG="nightelf druid" # just a body of that shape, level 1
//! ```
//!
@@ -26,14 +26,15 @@
//! **Race + class name a character, so the rig owns the pick.** vmangos has no class-change command
//! (`.character race` exists; there is no `.character class`), so a different class *is* a different
//! character — which makes creating one the honest primitive rather than a fallback. The name is
//! derived, never invented: `<Race3><Class3><slot-word>[f]`, e.g. Tauren Druid on `pool-1` →
//! `Taudruone`. Deterministic means the *next* session reuses the same body instead of littering a
//! second one, and the pattern is what makes eviction safe (below). Omit race+class and the rig
//! configures whatever `WOW_CHAR`/the slot default already logs in as.
//! derived, never invented: `<Race3><Class3><word>[f]`, the word being this checkout's
//! (`run_mode::rig_suffix`), e.g. a Tauren Druid for `Probeone` → `Taudruone`. Deterministic means
//! the *next* session reuses the same body instead of littering a second one, and the pattern is
//! what makes eviction safe (below). Omit race+class and the rig configures whatever `WOW_CHAR`
//! already logs in as.
//!
//! **The roster is a cache with an eviction policy.** `CharactersPerRealm` is 10, and there are 40
//! valid race/class pairs, so a busy account fills. The rig does not hardcode the limit: it tries
//! the create, and only on `CHAR_CREATE_SERVER_LIMIT` evicts **the rig-named character on this slot
//! the create, and only on `CHAR_CREATE_SERVER_LIMIT` evicts **the rig-named character of this checkout
//! that is cheapest to rebuild** — lowest level first, ties broken by oldest — then retries once.
//! Nothing outside the rig's own naming pattern is ever deleted, so `Probe<N>` and anything a human
//! made are untouchable.
@@ -44,7 +45,7 @@
//! It always revives a dead or ghost body, whether or not you asked — that is the 121× command, and
//! there is no session that wants to keep testing on a corpse.
//!
//! ## The GM verbs behind it (all verified against `/Users/sam/wre/vmangos-src`)
//! ## The GM verbs behind it (all verified against the vmangos source, github.com/vmangos/core)
//!
//! | rig token | command | needs | note |
//! |---|---|---|---|
@@ -71,7 +72,7 @@
//! character (give `WOW_RIG` a race+class) starts with empty bags and is the cheaper path.
//!
//! Non-combat throughout: the rig creates, configures, places and stops. It never fights, so the
//! unattended-combat ban (method.md) is untouched.
//! unattended-combat ban (docs/METHOD.md) is untouched.
use benilla_protocol::{messages, CharAction, CharCreateReq};
use bevy::prelude::*;
@@ -225,7 +226,7 @@ impl RigSpec {
fn describe(&self) -> String {
let body = self
.body
.map_or("this slot's probe character".into(), |(r, c, g)| {
.map_or("the account's probe character".into(), |(r, c, g)| {
format!(
"{} {} {}",
if g == 1 { "female" } else { "male" },
@@ -360,13 +361,13 @@ fn drive_rig(
messages::CHAR_CREATE_SUCCESS => rig.phase = RigPhase::AwaitRoster,
messages::CHAR_CREATE_SERVER_LIMIT if !rig.evicted => {
rig.evicted = true;
match crate::run_mode::slot_word().and_then(|slot| {
evictable(&rig.roster, rig_char_name(&rig.spec).as_deref(), slot)
match crate::run_mode::rig_suffix().and_then(|word| {
evictable(&rig.roster, rig_char_name(&rig.spec).as_deref(), &word)
}) {
Some((guid, name)) => {
warn!(
"rig: the account is full — evicting the cheapest rig character to rebuild, {name} \
(guid {guid}). Only rig-named characters on this slot are ever \
(guid {guid}). Only this checkout's rig-named characters are ever \
deleted; Probe<N> and hand-made characters are never touched."
);
let _ = pick.0.send(CharRequest::Delete(guid));
@@ -518,7 +519,7 @@ fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
/// `game_tele` name. The facing is load-bearing exactly when a motion probe walks from the
/// pin: `W` follows the BODY's facing, and each probe character keeps whatever facing it last
/// had — an unpinned facing sent probe0 and probe4 down different routes from the same point,
/// which invalidated a cross-slot A/B before 1462's sitting caught it.
/// which invalidated a cross-checkout A/B before 1462's sitting caught it.
fn parse_point(at: &str) -> Option<(i32, f32, f32, f32, Option<f32>)> {
let mut parts = at.split(',').map(str::trim);
let map = parts.next()?.parse().ok()?;
@@ -536,9 +537,9 @@ fn parse_point(at: &str) -> Option<(i32, f32, f32, f32, Option<f32>)> {
fn rig_char_name(spec: &RigSpec) -> Option<String> {
let (race, class, gender) = spec.body?;
let slot = crate::run_mode::slot_word()?;
let word = crate::run_mode::rig_suffix()?;
let name = format!(
"{}{}{slot}{}",
"{}{}{word}{}",
race_code(race)?,
class_code(class)?,
if gender == 1 { "f" } else { "" }
@@ -550,45 +551,45 @@ fn rig_char_name(spec: &RigSpec) -> Option<String> {
Some(chars.next()?.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase())
}
/// The rig-named character on this slot that costs least to lose: **lowest level first**, ties
/// The rig-named character of this checkout that costs least to lose: **lowest level first**, ties
/// broken by oldest (the enum is ordered by `create_time` — vmangos `HandleCharEnumOpcode` — so an
/// earlier index *is* older). Level is the proxy for invested setup: a level-1 body is 15 seconds to
/// rebuild, a geared 60 is a minute and a talent tree. Evicting by age alone would throw away the
/// most valuable body on the account first, which the live fill test made obvious.
///
/// Anything that is not a rig name for `slot` — `Probe<N>`, a hand-made character, another slot's
/// leftovers — is invisible here, and that is what makes automatic deletion safe.
/// Anything that is not a rig name for `word` — the probe body itself, a hand-made character,
/// another checkout's leftovers — is invisible here, and that is what makes automatic deletion safe.
///
/// `slot` is a **parameter, not a `slot_word()` call inside**: reading the ambient slot here made
/// the eviction test pass only in the worktree it was written in (`pool-1`, whose rig names end
/// `…one`) and fail in every other slot — and in the primary checkout, where `slot_word()` is
/// `None` and nothing matches at all. A unit test's answer must not depend on which directory the
/// build happened in.
/// `word` is a **parameter, not a `rig_suffix()` call inside**: reading the ambient word here made
/// the eviction test pass only in the checkout it was written in (whose rig names end `…one`) and
/// fail in every other — and in a checkout with no declaration, where `rig_suffix()` is `None` and
/// nothing matches at all. A unit test's answer must not depend on which directory the build
/// happened in.
fn evictable(
roster: &[benilla_protocol::Character],
want: Option<&str>,
slot: &str,
word: &str,
) -> Option<(u64, String)> {
roster
.iter()
.enumerate()
.filter(|(_, c)| {
!want.is_some_and(|w| c.name.eq_ignore_ascii_case(w)) && is_rig_name(&c.name, slot)
!want.is_some_and(|w| c.name.eq_ignore_ascii_case(w)) && is_rig_name(&c.name, word)
})
.min_by_key(|(age, c)| (c.level, *age))
.map(|(_, c)| (c.guid, c.name.clone()))
}
/// Whether a roster name was minted by [`rig_char_name`] for this slot: a known race code, a known
/// class code, this slot's word, and nothing but an optional `f` after it.
fn is_rig_name(name: &str, slot: &str) -> bool {
/// Whether a roster name was minted by [`rig_char_name`] for this checkout: a known race code, a
/// known class code, this checkout's word, and nothing but an optional `f` after it.
fn is_rig_name(name: &str, word: &str) -> bool {
let lower = name.to_ascii_lowercase();
let Some(rest) = lower.get(3..).and_then(|r| r.get(3..)) else {
return false;
};
let has_codes = RACE_CODES.iter().any(|(_, c)| lower.starts_with(c))
&& CLASS_CODES.iter().any(|(_, c)| lower[3..].starts_with(c));
has_codes && (rest == slot || rest == format!("{slot}f"))
has_codes && (rest == word || rest == format!("{word}f"))
}
/// Race id → the 3-letter name code. `Nel`/`Und` rather than the DBC's own prefixes: unambiguous,
@@ -770,7 +771,7 @@ mod tests {
#[test]
fn the_derived_name_is_deterministic_and_fits_the_server_limit() {
// `<Race3><Class3><slot-word>[f]`, normalized the way vmangos normalizes a player name.
// `<Race3><Class3><word>[f]`, normalized the way vmangos normalizes a player name.
let name = |spec: &str, slot: &str| {
let s = RigSpec::parse(spec).unwrap();
let (race, class, gender) = s.body.unwrap();
@@ -835,9 +836,9 @@ mod tests {
#[test]
fn eviction_spends_the_body_that_is_cheapest_to_rebuild() {
// The slot is passed in, never read from the ambient build directory — these names are
// pool-1's, and taking the slot from `slot_word()` made this test pass in pool-1 and fail
// in every other worktree (and in the primary checkout, where it is `None`).
// The word is passed in, never read from the ambient checkout — these names are one
// checkout's, and taking the word from `rig_suffix()` made this test pass there and fail
// in every other checkout (and in one with no declaration, where it is `None`).
let slot = "one";
// Roster order IS create order (vmangos enumerates by `create_time`).
let roster = [
@@ -859,8 +860,8 @@ mod tests {
);
// Nothing rig-named on this slot ⇒ nothing to evict; the caller errors rather than guessing.
assert_eq!(evictable(&roster[..2], None, slot), None);
// And another slot's leftovers are invisible: the same roster, read as pool-3, evicts
// nothing. This is the assertion that would have caught the ambient-slot read.
// And another checkout's leftovers are invisible: the same roster, read with another word,
// evicts nothing. This is the assertion that would have caught the ambient read.
assert_eq!(evictable(&roster, None, "three"), None);
}
@@ -3,8 +3,7 @@
//! 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
//! `UNIT_NPC_FLAGS` ([`crate::target::click::service_arm`]; `0x5f0130`). **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
@@ -21,7 +20,7 @@
//! | *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`,
//! The flag column is live-DB verified against the local vmangos (`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.
//!
@@ -48,7 +47,7 @@
//! 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
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)). 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).
@@ -78,7 +77,7 @@ use crate::ui_trainer::TrainerOpen;
/// 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
/// timeout printed a FAIL about the client (docs/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.
@@ -215,7 +214,7 @@ struct ServiceProbe {
/// 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
/// A leg runner 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).
@@ -355,7 +354,7 @@ fn service_probe(
// the guid the wire addresses.
mut merchant: ResMut<MerchantOpen>,
script: Option<NonSendMut<UiScript>>,
items: Res<crate::items::Items>,
objects: crate::net::Objects,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
@@ -426,7 +425,7 @@ fn service_probe(
// 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 \
the NPC. Re-run on an idle machine (`uptime`; a leg runner's guard \
is load < 3).",
false =>
" — the machine was keeping up, so the `.go` was refused, the \
@@ -759,7 +758,7 @@ fn service_probe(
let Some(item_guid) = self_store
.single()
.ok()
.and_then(|s| crate::ui_items::slot_guid(&s.0, held.bag, slot0, &items))
.and_then(|s| crate::ui_items::slot_guid(&s.0, held.bag, slot0, &objects))
else {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — bag {} slot {} is on the \
@@ -10,7 +10,7 @@
//!
//! **Why a probe and not a capture:** none of this is a picture. Every reading here is a number or
//! a Lua truth value — the queued area id, `IsInMeetingStoneQueue()`, whether the stock minimap
//! button is shown — so the verdict needs no eye in the loop (`method.md` step 4).
//! button is shown — so the verdict needs no eye in the loop (`docs/METHOD.md` step 4).
//!
//! **Where it enters the chain, exactly.** It writes the very
//! [`MeetingStoneUse`] the click ladder writes, so everything **downstream** of that seam runs
@@ -44,8 +44,8 @@
//! WOW_NOSOUND=1 WOW_UNATTENDED=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
//! WOW_PROBE_STONE=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_STONE=<x>,<y>,<z>[,<map>]` aims it elsewhere.
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
//! skill). `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]` aims it elsewhere.
//!
//! **It leaves the probe body at the stone's minimum level**, deliberately and without putting it
//! back: the level is the probe's own instrument (leg 2 needs one side of the band and leg 3 the
+3 -4
View File
@@ -7,8 +7,7 @@
//! arrival distance to the destination node's DBC position, and measured flight duration vs the
//! DBC prediction `Σ path-segment length ÷ 32 yd/s` (`PLAYER_FLIGHT_SPEED`) — timing measured,
//! never eyeballed (decision 0404). An outer `timeout`d run + grep is the whole harness.
//! 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"). Uses `.taxicheat on` so
//! Non-combat. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill). Uses `.taxicheat on` so
//! the fresh probe character can fly to an unvisited node (and so the SHOWTAXINODES mask
//! exercises the full-network branch); the 110-copper fare is DB-seeded (see the Wait phase —
//! `.modify money` outranks the probe account).
@@ -95,7 +94,7 @@ enum Phase {
started_at: f64,
last_report: f64,
/// Latched true the first in-flight frame the anim pair reads right — rider base
/// Mount(91), mount child base Fly(135) (RF-0057 `0x5fd19c` + the 0441 mount pin).
/// Mount(91), mount child base Fly(135) (`0x5fd19c` + the 0441 mount pin).
gait_ok: bool,
/// The largest |flying pitch| (radians) seen on the SELF transform mid-flight — the
/// `sample_splines` tangent-climb attitude (decisions 0501/0516). The route climbs
@@ -308,7 +307,7 @@ fn taxi_probe(
} => {
let wow = bevy_to_wow(player.pos);
if player.server_riding() {
// The anim pair the flight must show (RF-0057 `0x5fd19c` + the 0441 mount pin):
// The anim pair the flight must show (`0x5fd19c` + the 0441 mount pin):
// rider base Mount(91), mount child base Fly(135). Latched — the first frames
// legitimately lag (mount attach, first selection).
let rider = drivers.get(self_entity).ok().map(|d| d.playing().0);
@@ -36,7 +36,7 @@
//! 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").
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)).
use bevy::prelude::*;
@@ -2,8 +2,8 @@
//! the body saying what the wire asked for, what we resolved, and what is actually hanging off the
//! skeleton right now.
//!
//! It exists because those are three different things and a screenshot conflates all three. B123 —
//! *"show helm and cloak preferences are not taken into account"* — is exactly a gap between the
//! It exists because those are three different things and a screenshot conflates all three. A
//! helm or cloak the player chose to hide, drawn anyway, is exactly a gap between the
//! first and the third: the descriptor carried `PLAYER_FLAGS_HIDE_HELM`, the resolver ignored it,
//! and a helm model hung off the head. Nothing in a picture separates that from "this character
//! simply has a helm equipped", which is why the report needed a reader rather than an eye.
@@ -22,7 +22,7 @@
//! - **`terrain`** is the MCNK height under it, which is what the clamp finds when a building's own
//! collider has not attached yet.
//!
//! Pair it with the slot-keyed probe identity and a `.go` to the reported spot:
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` to the reported spot:
//!
//! ```text
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
@@ -31,7 +31,7 @@
//! - **`meshes`** counts render descendants (the whole subtree, not just direct children — a GO
//! model hangs its submeshes under an anim host).
//!
//! Pair it with the slot-keyed probe identity and a `.go` to the reported spot:
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` to the reported spot:
//!
//! ```text
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
@@ -125,9 +125,9 @@ fn fire_lift_census(
for (guid, net, t, store, transport, anchor, seed, vis, inherited, kids, attached) in &entities
{
let go_type = store.0.gameobject_type_id();
// Every ticking GO type (the reference's own RF-0051 pair) plus anything already wearing
// a transport component — so a type field we misread still shows up rather than vanishing
// from the instrument that exists to find it.
// Every ticking GO type (the pair the reference's per-frame tick `0x630970` fires) plus
// anything already wearing a transport component — so a type field we misread still shows
// up rather than vanishing from the instrument that exists to find it.
if !(net.kind == EntityKind::GameObject && matches!(go_type, 11 | 15)
|| transport.is_some()
|| anchor.is_some()
@@ -272,7 +272,7 @@ fn drive_live_fps(
// settle window on top — a run still worldless this far past it is stranded on a
// glue screen (dead server, refused login the marker arms didn't catch) and every
// second more is the 1371 sitting's dead wall-clock again. `FATAL` is the marker
// leg.sh keys on.
// a leg runner keys on.
if time.elapsed_secs() > probe.at + BOOT_DEADLINE_SECS {
error!(
"live-fps: FATAL — still not in world {:.0}s past the probe delay; a measurement run with no world is dead. exiting",
@@ -89,7 +89,7 @@ fn fire_particle_census(
// included — has already been discarded by the wall. Before 0678 it was routinely non-zero,
// because `doodad_fade_alpha` admits any owner over `NEVER_FADE_RADIUS` at *every* distance
// and nothing else bounded depth; that is precisely "all effects render at unlimited
// distance", and precisely the reporter's "the terrain is not even rendered that far".
// distance", out past the point where the terrain itself stops drawing.
// It must now be **0**: past the wall the gate hides the emitter and freezes its pool.
//
// `beyond_wall` (verdict ignored) stays as the denominator — emitters *exist* out there and
+1 -1
View File
@@ -8,7 +8,7 @@ use bevy::prelude::*;
use super::ProbeClock;
/// Keep a probe run's window **un-occludable, and out of the director's way** — the one defence
/// against macOS's ~1 fps throttle for a fully covered window (decisions 0713/0777, method.md's
/// against macOS's ~1 fps throttle for a fully covered window (decisions 0713/0777, docs/METHOD.md's
/// `caffeinate` note), at the smallest footprint that still buys it.
///
/// The on-top half used to live inside the FPS probe alone, which reads as "a frame-rate concern".
@@ -32,7 +32,7 @@
//! Off a transport `deck=` reads `-` and the deck columns are the world ones, which is the control:
//! this census must not change on the ground, where a streak *should* smear behind a moving host.
//!
//! Pair it with the slot-keyed probe identity and a `.go` onto the Thunder Bluff lift:
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` onto the Thunder Bluff lift:
//!
//! ```text
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
@@ -38,7 +38,7 @@
//! never to be confused with one that built nothing, which is the distinction the census exists
//! to keep.
//!
//! Pair it with the slot-keyed probe identity and a `.go` to the reported spot:
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` to the reported spot:
//!
//! ```text
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
+1 -1
View File
@@ -1441,7 +1441,7 @@ pub(super) const SCAR_LOOK: [f32; 3] = [-11792.7, -2647.1, 66.6];
/// **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
/// instrument that manufactures a confident false negative — the shape `docs/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.
///
+117 -3
View File
@@ -326,7 +326,7 @@ impl CreateSelection {
}
/// One clickable control on the screen — a single component so one query dispatches every button.
#[derive(Component, Clone, Copy, PartialEq, Eq)]
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
enum CreateAction {
Race(u8),
Gender(u8),
@@ -372,8 +372,8 @@ fn class_file(class: u8) -> &'static str {
// ── Input ────────────────────────────────────────────────────────────────────────────────────────
/// The name box's caret blink clock (the ref's `blinkSpeed`, f32 default 0.5 s — wow-re
/// `rf82-editbox-runtime.md`, period `E+0x370` / accumulator `E+0x374`), reset on every keystroke
/// The name box's caret blink clock (the ref's `blinkSpeed`, f32 default 0.5 s — `0x77a790`,
/// period `E+0x370` / accumulator `E+0x374`), reset on every keystroke
/// so the caret is solid while you type.
///
/// Its own resource rather than a [`CreateSelection`] field on purpose: ticking it there would trip
@@ -808,4 +808,118 @@ mod tests {
"the booth look must differ by class, or the preview cannot re-dress"
);
}
/// **The chosen race / gender / class icon is LOCK-HIGHLIGHTED** — the reference's own verb:
/// `SetCharacterRace`, `SetCharacterClass` and `SetCharacterGender` each call
/// `button:LockHighlight()` on the one that is chosen and `UnlockHighlight()` on the rest
/// (`CharacterCreate.lua` l.171/254/326). In 1.12 that lock *is* the whole selected visual:
/// `CharacterCreateIconButtonTemplate` has its `<CheckedTexture>` commented out, leaving the
/// ADD `ButtonHilight-Square` and the HIGHLIGHT-layer `$parentHighlightText` — both of which
/// the lock is what lights.
///
/// **The regression this exists for** (director, 2026-09-17: *"it doesn't show that mage is
/// selected"*): 2072 moved the sheen to one owner and gave this screen's visuals query a
/// `&mut LockHighlight` term — but [`crate::glue::widgets::icon_button`], the only spawn site
/// for all eighteen of these icons, inserted no such component. A `&mut T` term is a filter,
/// so the query matched **nothing**: no selected sheen and no icon name on race, gender or
/// class, for ten days. Every existing sheen test hand-spawned the flag, so the consumer was
/// covered and the producer was not — hence this one builds the icons with the real
/// `icon_button` and runs the real system.
#[test]
fn the_chosen_icons_are_lock_highlighted() {
use crate::glue::art::GlueArt;
use crate::glue::widgets::{icon_button, LockHighlight};
fn spawn_icons(mut commands: Commands, art: Res<GlueArt>) {
let font = Handle::<Font>::default();
commands.spawn(Node::default()).with_children(|p| {
for race in ALLIANCE {
icon_button(
p,
&font,
CreateAction::Race(race),
None::<parts::DynIcon>,
None,
None::<parts::DynText>,
"",
&art,
1.0,
);
}
for sex in 0..2u8 {
icon_button(
p,
&font,
CreateAction::Gender(sex),
None::<parts::DynIcon>,
None,
None::<parts::DynText>,
"",
&art,
1.0,
);
}
for slot in 0..8u8 {
icon_button(
p,
&font,
CreateAction::ClassSlot(slot),
None::<parts::DynIcon>,
None,
None::<parts::DynText>,
"",
&art,
1.0,
);
}
});
}
let mut app = App::new();
app.init_resource::<GlueArt>()
// Human, female, MAGE — the director's own case. With no catalog loaded
// `race_classes` is the full list, so mage (8) is slot 6.
.insert_resource(CreateSelection {
race: 1,
sex: 1,
class: 8,
..default()
})
.add_systems(Startup, spawn_icons)
.add_systems(Update, refresh::refresh_hover);
app.update();
let locked: Vec<CreateAction> = app
.world_mut()
.query::<(&CreateAction, &LockHighlight)>()
.iter(app.world())
.filter(|(_, l)| l.0)
.map(|(a, _)| *a)
.collect();
assert_eq!(
locked.len(),
3,
"exactly one race, one gender and one class icon is locked — got {locked:?}"
);
assert!(locked.contains(&CreateAction::Race(1)), "Human");
assert!(locked.contains(&CreateAction::Gender(1)), "female");
assert!(
locked.contains(&CreateAction::ClassSlot(6)),
"mage is slot 6 of [1,2,3,4,5,7,8,9,11] — and it is the mage icon the director saw \
unmarked"
);
// …and the selection moving takes the lock with it, rather than lighting a second icon.
app.world_mut().resource_mut::<CreateSelection>().class = 1; // warrior, slot 0
app.update();
let locked: Vec<CreateAction> = app
.world_mut()
.query::<(&CreateAction, &LockHighlight)>()
.iter(app.world())
.filter(|(_, l)| l.0)
.map(|(a, _)| *a)
.collect();
assert!(locked.contains(&CreateAction::ClassSlot(0)));
assert!(!locked.contains(&CreateAction::ClassSlot(6)));
}
}
+22 -10
View File
@@ -219,11 +219,17 @@ pub(super) fn refresh_dynamic(
}
}
/// Per-frame interaction visuals the CREATE screen owns: highlight overlays (hover — and held
/// while selected, the ref's `LockHighlight`), hover labels, and the Create button's disabled
/// latch while a create is in flight. The screen-agnostic passes — up/down art swaps, the glue
/// buttons' art + caption color, outline mirroring — are [`crate::glue`]'s, registered beside
/// this in the plugin chain.
/// Per-frame interaction visuals the CREATE screen owns: which icon is *chosen* (the ref's
/// `LockHighlight`, which [`crate::glue::glue_hilights`] then renders), hover labels, the no-art
/// fallback shade, and the Create button's disabled latch while a create is in flight. The
/// screen-agnostic passes — up/down art swaps, the glue buttons' art + caption color, outline
/// mirroring — are [`crate::glue`]'s, registered beside this in the plugin chain.
///
/// **The chosen-icon write is its own query, deliberately.** It used to be a sixth term on the
/// hover query below, which meant a button without a [`LockHighlight`] dropped out of the hover
/// visuals too — and since no spawn site on this screen had one, that query matched *nothing*:
/// no selected sheen and no icon name anywhere on the create screen. Two jobs, two queries, so a
/// missing component can only ever cost its own job.
#[allow(clippy::type_complexity)]
pub(super) fn refresh_hover(
sel: Res<CreateSelection>,
@@ -238,10 +244,10 @@ pub(super) fn refresh_hover(
&Children,
&mut BackgroundColor,
Has<FallbackFace>,
&mut LockHighlight,
),
(With<Button>, Without<crate::glue::widgets::GlueBtn>),
>,
mut locks: Query<(&CreateAction, &mut LockHighlight)>,
mut labels: Query<&mut Visibility, (With<HoverLabel>, Without<Hilight>)>,
mut disables: Query<(&CreateAction, &mut GlueDisabled)>,
) {
@@ -254,14 +260,20 @@ pub(super) fn refresh_hover(
_ => false,
};
for (action, interaction, children, mut bg, fallback, mut locked) in &mut buttons {
// `SetCharacterRace`/`SetCharacterClass`/`SetCharacterGender`: `LockHighlight()` on the one
// that is chosen, `UnlockHighlight()` on the rest. Nothing about visibility — the sheen is
// `crate::glue::glue_hilights`' alone.
for (action, mut locked) in &mut locks {
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;
}
}
for (action, interaction, children, mut bg, fallback) in &mut buttons {
let is_sel = selected(action);
let hovered = *interaction != Interaction::None;
let lit = is_sel || hovered;
// 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 {
+181 -59
View File
@@ -38,9 +38,12 @@
//!
//! At character select no addon has been *loaded* — `load_third_party` is a world-entry step
//! (1191 §2) — so `GetNumAddOns()` would answer 0. The screen asks
//! [`crate::ui_script::addons::installed`] instead: the same discovery, the same manifest parser,
//! the same enable files. Two views of one folder, never two folders. With the dropdown that read
//! happens **once per roster character at open** ([`AddonsPanel::open_for`]).
//! [`crate::ui_script::addons::installed_rows`] instead, and resolves the enable bits through
//! [`crate::ui_script::addons::EnableStore`] exactly as the world-entry walk does: the same
//! discovery, the same manifest parser, the same store. Two views of one folder, never two
//! folders. Both reads happen **once at open** ([`AddonsPanel::open_for`]) — the folder walk used
//! to run once per roster character, which decision 2311 retired along with the per-character
//! `installed()` it was there to feed.
use bevy::prelude::*;
use bevy::ui_render::ui_material::MaterialNode;
@@ -224,41 +227,34 @@ impl Default for AddonsPanel {
}
impl AddonsPanel {
/// Open for this realm's roster, reading the folder fresh — once per character, so each
/// staged column is that character's own enable file applied to one shared list. The
/// reference's `AddonList_OnShow` also re-reads (`AddonList_Update`), so a folder that
/// changed under us is picked up.
/// Open for this realm's roster: the folder read **once** into one metadata list, and one
/// staged column per character off the enable store. The reference's `AddonList_OnShow` also
/// re-reads (`AddonList_Update`), so a folder that changed under us is picked up.
///
/// **The store is loaded for the whole roster, not per character** (decision 2311). A column
/// is not "that character's file, everything else enabled": an addon the character has no row
/// for takes what the realm's *other* characters agree on, and only falls to the manifest's
/// `## DefaultState` when they disagree or nobody has said anything. Reading it the other way
/// is what made a newly created character re-enable every addon in the All view — its
/// all-enabled column turned every disabled row Mixed, and Mixed reads as on.
pub(super) fn open_for(&mut self, realm: String, chars: Vec<String>) {
self.list.clear();
self.list = addons::installed_rows();
self.staged.clear();
if chars.is_empty() {
// A fresh account: no character means no enable file to key. `None` is the "nobody's
// file" read (everything enabled), staged into one anonymous column so the boxes
// still toggle; Okay has no file to write ([`Self::save_staged`] walks `chars`).
self.list = addons::installed(None);
self.staged
.push(self.list.iter().map(|a| a.enabled).collect());
let store = addons::EnableStore::load(&realm, &chars);
// A fresh account stages one anonymous column: no character means no file to key, and
// Okay has none to write ([`Self::save_staged`] walks `chars`).
let columns: Vec<Option<&str>> = if chars.is_empty() {
vec![None]
} else {
for (c, name) in chars.iter().enumerate() {
let id = (realm.clone(), name.clone());
let rows = addons::installed(Some(&id));
if c == 0 {
self.staged.push(rows.iter().map(|a| a.enabled).collect());
self.list = rows;
} else {
// 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(),
"installed() must be deterministic across per-character reads"
);
self.staged.push(rows.iter().map(|a| a.enabled).collect());
}
}
chars.iter().map(|c| Some(c.as_str())).collect()
};
for character in columns {
self.staged.push(
self.list
.iter()
.map(|a| store.enabled_for(&a.name, a.default_state, character))
.collect(),
);
}
self.baseline = self.staged.clone();
self.realm = realm;
@@ -316,7 +312,7 @@ impl AddonsPanel {
/// actually matters.
pub(super) fn any_installed() -> bool {
static ANY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ANY.get_or_init(|| !addons::installed(None).is_empty())
*ANY.get_or_init(|| !addons::installed_rows().is_empty())
}
/// The rows currently visible, as `(index, addon)` — enable state is the view's to answer
@@ -392,10 +388,12 @@ impl AddonsPanel {
}
/// Is this addon enabled *for the current view* — the bit the gate is fed? A single character
/// reads their own column. The All view answers **"any staged character has it on"** — OURS
/// (1293): the reference feeds `AddOn_CanLoad` a NULL character there and that read's exact
/// semantics are un-carved, so we state the natural reading rather than guess at bytes nobody
/// has verified.
/// reads their own column. The All view answers **"any staged character has it on"**, which
/// 1293 stated as the natural reading of an untraced mechanism and which the bytes have
/// since confirmed: `AddonList_Update` computes `enabled = (checkboxState > 0)` off
/// `GetAddOnEnableState(nil, i)`, whose `1` (enabled for some) therefore counts as on
/// (`0x51e470`). What 1293 got wrong was not this fold but what each column holds — see
/// [`Self::open_for`] and decision 2311.
fn effective_enabled(&self, i: usize) -> bool {
self.box_state(i) != BoxState::Off
}
@@ -606,7 +604,7 @@ pub(super) fn drive_addons_panel(
for (entity, action, interaction) in &hovers {
// **The scroll bar warps on the PRESS**, and only it: the reference's `CSimpleSlider`
// OnMouseDown (`0x789ca0`, 45 bytes, zero branches) warps the value from any press inside
// the hit rect — there is no thumb hit-test in the class (wow-re `ui.md`). Every *button*
// the hit rect — there is no thumb hit-test in the class. Every *button*
// in this panel fires on the RELEASE, over the button that took the press (1533).
let click = if *action == AddonsAction::ScrollBar {
interaction.is_changed() && *interaction == Interaction::Pressed
@@ -694,7 +692,7 @@ pub(super) fn drive_addons_panel(
}
}
// The live `checkAddonVersion` mirror (1293 §5): read per frame like the gate's own per-query
// The live `checkAddonVersion` mirror (1293): read per frame like the gate's own per-query
// read (1292 §2.2), so the ForceLoad click above — or any other writer — repaints the
// statuses this same frame with nothing rescanned. Absent VM or table = the registrar
// default: check ON.
@@ -1712,7 +1710,7 @@ mod tests {
dependencies: deps.iter().map(|d| (*d).to_string()).collect(),
interface: iface,
load_on_demand: false,
enabled: true,
default_state: true,
}
}
@@ -1721,7 +1719,7 @@ mod tests {
/// `p.view = Some(c)` themselves.
fn panel_for(chars: usize, list: Vec<InstalledAddOn>) -> AddonsPanel {
let staged: Vec<Vec<bool>> = (0..chars.max(1))
.map(|_| list.iter().map(|a| a.enabled).collect())
.map(|_| list.iter().map(|a| a.default_state).collect())
.collect();
AddonsPanel {
open: true,
@@ -1848,7 +1846,7 @@ mod tests {
);
}
/// The All view's gate input is OUR carve (1293): "enabled = any staged character has it on".
/// The All view's gate input is OUR reading (1293): "enabled = any staged character has it on".
#[test]
fn the_all_view_feeds_the_gate_any_character_on() {
let mut p = panel_for(
@@ -1934,7 +1932,7 @@ mod tests {
assert_eq!(p.staged, vec![vec![true, true]]);
p.staged[0][0] = false;
assert!(
p.list[0].enabled,
p.list[0].default_state,
"the read-in list is never mutated — only `staged` is, which is what makes Cancel free"
);
p.close();
@@ -1982,27 +1980,151 @@ mod tests {
p.staged[1][1] = false;
p.save_staged();
// Read back through the same folder view the world-entry walk uses — the two views of
// one folder that 1197 §3 demands can never disagree.
let alice = ("TestRealm".to_string(), "Alice".to_string());
let bob = ("TestRealm".to_string(), "Bob".to_string());
let carol = ("TestRealm".to_string(), "Carol".to_string());
let read = |id: &(String, String)| -> Vec<bool> {
addons::installed(Some(id))
.iter()
.map(|a| a.enabled)
// Read back through the same store the world-entry walk resolves against — the two views
// of one folder that 1197 §3 demands can never disagree.
let roster = ["Alice".to_string(), "Bob".to_string(), "Carol".to_string()];
let store = addons::EnableStore::load("TestRealm", &roster);
let rows = addons::installed_rows();
let read = |who: &str| -> Vec<bool> {
rows.iter()
.map(|a| store.enabled_for(&a.name, a.default_state, Some(who)))
.collect()
};
assert_eq!(
read(&alice),
read("Alice"),
vec![false, true],
"Alice's file carries HER edit"
);
assert_eq!(read(&bob), vec![true, false], "Bob's carries HIS");
assert_eq!(read("Bob"), vec![true, false], "Bob's carries HIS");
let carol = ("TestRealm".to_string(), "Carol".to_string());
assert!(
!addons::enable_state_path(Some(&carol)).unwrap().exists(),
"an unchanged column writes no file — only the diffs against the open-time baseline"
);
// And Carol, who has no file at all, is NOT "everything enabled": she takes what the
// others agree on, which here is nothing — Alpha and Beta are each on for one and off
// for the other — so both fall to their manifests' `## DefaultState` (2311).
assert_eq!(read("Carol"), vec![true, true]);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **Creating a character does not re-enable everything** — the director's report,
/// 2026-09-17: *"after creating a new char all addons are enabled again, even after disabling
/// all of them just before"*.
///
/// The walk is theirs, verbatim: Disable All in the All view, Okay, create a character, open
/// the list again. What used to happen is the whole bug in one line — the new character had
/// no `AddOns.txt`, benilla read an absent row as **enabled**, and that all-true column turned
/// every row in the All view from Off to Mixed, which paints a check and reads as on
/// (`effective_enabled`). One click there would then have written the all-enabled state back
/// onto every other character's file: a one-way ratchet.
///
/// The reference does not do this: its enable query `0x51e470` counts only nodes with an
/// **explicit** entry, so a character with no file contributes no opinion and inherits the
/// aggregate — here, the unanimous `disabled` the player just saved. Decision 2311.
#[test]
fn a_new_character_inherits_the_disable_it_did_not_ask_for() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp =
std::env::temp_dir().join(format!("benilla-charsel-newchar-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let home = tmp.join("benilla-config");
for name in ["Alpha", "Beta"] {
let dir = home.join("AddOns").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.toc")), "## Interface: 11200\n").unwrap();
}
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let _h =
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
// The roster before the create, and the player's Disable All + Okay over it.
let mut p = AddonsPanel::default();
p.open_for(
"TestRealm".into(),
vec!["Onemage".into(), "Onerogue".into()],
);
p.set_all(false);
p.save_staged();
p.close();
// …then a character is created, so the roster the panel opens over has one more name and
// that name has no enable file at all.
p.open_for(
"TestRealm".into(),
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
);
assert!(p.view.is_none(), "reopens on All, the reference's default");
assert_eq!(p.staged.len(), 3);
assert_eq!(
p.staged[2],
vec![false, false],
"the new character inherits the unanimous disable, not a blank slate"
);
for i in 0..p.list.len() {
assert_eq!(
p.box_state(i),
BoxState::Off,
"row {i} reads OFF in the All view — Mixed here is the bug, and it paints a check"
);
assert!(!p.effective_enabled(i), "…so nothing is loadable either");
}
let _ = std::fs::remove_dir_all(&tmp);
}
/// The other side of the same law: a new character does **not** inherit a disable the roster
/// never agreed on. One character off, one on, and the newcomer takes the manifest's
/// `## DefaultState` — so this is not "new characters copy whoever is strictest".
#[test]
fn a_new_character_inherits_nothing_when_the_roster_disagrees() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp =
std::env::temp_dir().join(format!("benilla-charsel-split-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let home = tmp.join("benilla-config");
for name in ["Alpha", "Beta"] {
let dir = home.join("AddOns").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{name}.toc")),
// Beta ships `## DefaultState: disabled`, so the tie-break is visible.
if name == "Beta" {
"## Interface: 11200\n## DefaultState: disabled\n"
} else {
"## Interface: 11200\n"
},
)
.unwrap();
}
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let _h =
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
let mut p = AddonsPanel::default();
p.open_for(
"TestRealm".into(),
vec!["Onemage".into(), "Onerogue".into()],
);
p.view = Some(0);
p.set_all(false); // the mage turns both off; the rogue keeps the defaults
p.view = Some(1);
p.set_all(true);
p.save_staged();
p.close();
p.open_for(
"TestRealm".into(),
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
);
assert_eq!(
p.staged[2],
vec![true, false],
"split roster → each row falls to its own `## DefaultState`"
);
let _ = std::fs::remove_dir_all(&tmp);
}
@@ -2089,8 +2211,8 @@ mod tests {
}
/// A press on the TRACK — off the knob — warps the knob's centre under the cursor and drags
/// on from there, one gesture. Byte-verified 1.12 (wow-re `slider-mouse-law.md` §1, and the
/// director's own requirement in 0989); the glue bar runs the same law as every in-game one.
/// on from there, one gesture. 1.12 does this (`0x789ca0`, and the director's own requirement
/// in 0989); the glue bar runs the same law as every in-game one.
#[test]
fn a_press_on_the_bare_track_warps_the_knob_under_the_cursor() {
let thumb_n = KNOB / BAR_H;
+144 -29
View File
@@ -154,11 +154,14 @@ impl Plugin for CharSelectPlugin {
input::rotate_model,
debug_select_dialog,
dialog::drive_delete_dialog,
// Ahead of the dialog driver, so a refused delete is on screen the frame
// its answer lands rather than the next.
delete_result,
// 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.
// screen carries the refused character login and the refused delete. 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).
@@ -168,7 +171,6 @@ impl Plugin for CharSelectPlugin {
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
refresh::feed_glue_preview,
delete_result,
debug_select_shot,
)
.chain()
@@ -210,16 +212,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 (wow-re
/// `glue/scratch/glue-preview-facing-law.md`, 1533). A counter rather than change-detection on
/// `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.
/// dominates both legs, and the merged tail re-applies it geometrically (1533). A counter
/// rather than change-detection on `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.
/// rather than in the engine — see [`Roster::click_row`] and decision 2194. 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>,
@@ -303,9 +305,8 @@ impl Roster {
/// 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.
/// recorded (`0x472740`), 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));
@@ -374,7 +375,7 @@ impl Roster {
// ── The remembered character (`lastCharacterIndex`, decision 1622) ───────────────────────────────
/// The CVar the select screen remembers you by — a **real 1.12 CVar**, byte-verified in wow-re
/// The CVar the select screen remembers you by — a **real 1.12 CVar**
/// (registered at `0x402d93`, name `0x82e8f8`, help "Last character selected", default `"0"`,
/// pointer cached at `[0x882674]`), and written engine-side: no shipped GlueXML names it and the
/// binary never looks it up by name.
@@ -579,7 +580,7 @@ fn apply_roster_policy(
warn!("char select: WOW_CHAR={name} not on this account — showing roster");
// A driverless run can never pick a different row itself — parked here it
// burns its whole wall-clock. Same home as the login arm's verdict, so the
// marker leg.sh greps for is written in exactly one place (1371, 1769).
// marker a leg runner greps for is written in exactly one place (1371, 1769).
if std::env::var_os("WOW_LOGIN_SMOKE").is_none()
&& crate::run_mode::fatal_when_driverless(&format!(
"WOW_CHAR={name} is not on this account"
@@ -668,9 +669,8 @@ fn back_on_login_refused(
/// **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.)
/// (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
@@ -723,19 +723,67 @@ fn back_on_logout(
}
}
/// Surface a refused delete (the roster refresh already reflects a success — the row vanishes).
/// A refusal is realistically unreachable on vmangos (any enumerated character deletes), so a log
/// line honest-flags it rather than growing an error dialog nothing can trigger.
fn delete_result(mut msgs: MessageReader<CharActionResultMessage>) {
/// Surface a refused delete in the glue dialog; a success needs nothing here, since `net::io`
/// re-enumerates before the result and the row is already gone.
///
/// **A refusal is reachable, and was silent.** vmangos refuses to delete a guild master
/// (`HandleCharDeleteOpcode`: `GetGuildByLeader` → `CHAR_DELETE_FAILED`), and this used to be a
/// `warn!` under a comment calling the refusal unreachable — so the player pressed Delete, typed
/// DELETE, and watched nothing happen.
///
/// The reference's shape, read off `WoW.exe`: `SMSG_CHAR_DELETE 0x5b45c0` hands the result byte to
/// `0x5ab0e0`, which passes it **straight through** as the operation status
/// (`SetStatus(byte == 0x39, byte)`); `CGlueMgr::Update`'s `screenState == 6` arm (`0x46c14e`)
/// then, on a completed-and-failed operation, fires `OPEN_STATUS_DIALOG("OKAY", text)`
/// (`0x46c177` pushes `"OKAY"` `0x837084`) with the status text the key table `0x85cae8` names —
/// the one-button dialog [`crate::glue::dialog::GlueDialog::open_error`] is.
fn delete_result(
mut msgs: MessageReader<CharActionResultMessage>,
mut dialog: ResMut<crate::glue::dialog::GlueDialog>,
strings: Option<Res<GlueStrings>>,
) {
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
for msg in msgs.read() {
if msg.action == CharAction::Delete
&& msg.code != benilla_protocol::messages::CHAR_DELETE_SUCCESS
{
warn!("char select: delete refused (code {:#04x})", msg.code);
if msg.action != CharAction::Delete {
continue;
}
if let Some(text) = char_delete_refusal_text(strings, msg.code) {
info!(
"char select: delete refused (code {:#04x}) — {text}",
msg.code
);
dialog.open_error(text);
}
}
}
/// A `SMSG_CHAR_DELETE` result byte → the sentence the refusal dialog says, or `None` for the
/// success (`0x39`), which the refreshed roster says instead.
///
/// The keys are the `CHAR_DELETE_*` block of the reference's status-key table `0x85cae8`
/// (`0x38..=0x3b`), numbered exactly as vmangos's `ResponseCodes` (`SharedDefines.h`). vmangos
/// sends only two of them: `0x39` on success and `0x3a` for a guild master; its three other
/// refusals (a character still loaded, one not found, one on another account) send nothing at all.
///
/// **The default arm is benilla's, not the reference's.** The reference indexes the whole 83-row
/// table with the byte, so an out-of-block byte would read some other family's sentence (and one
/// past `0x52` would read `"(%i)"`); that table is not transcribed here, and no server we speak to
/// sends such a byte, so anything unnamed says the block's own generic failure.
pub(crate) fn char_delete_refusal_text(strings: &GlueStrings, code: u8) -> Option<&str> {
let (key, fallback): (&str, &str) = match code {
benilla_protocol::messages::CHAR_DELETE_SUCCESS => return None,
0x38 => ("CHAR_DELETE_IN_PROGRESS", "Deleting character"),
0x3B => (
"CHAR_DELETE_FAILED_LOCKED_FOR_TRANSFER",
"Your character is currently locked as part of the paid character transfer process.",
),
// `0x3a` — the guild-master refusal — and the default arm above.
_ => ("CHAR_DELETE_FAILED", "Character deletion failed"),
};
Some(strings.text(key, fallback))
}
/// Glue-flow smoke (`WOW_GLUE_ROUNDTRIP=1`, decision 0423): once a real roster is up, bounce
/// CharSelect → CharCreate → **Back** → CharSelect and exit — so the return-to-select rebuild is
/// provable headlessly from the logs. Runs ungated (it crosses states); inert without the env.
@@ -1388,6 +1436,73 @@ mod tests {
}
}
/// **A refused delete is said, not swallowed.** vmangos refuses to delete a guild master with
/// `CHAR_DELETE_FAILED` (0x3a); the screen used to `warn!` into the log and show nothing, so
/// the confirmed delete just silently did not happen. The reference raises its one-button
/// glue dialog with the `CHAR_DELETE_*` status text (`CGlueMgr::Update`'s `screenState == 6`
/// arm). A success raises nothing — the refreshed roster is the answer.
#[test]
fn a_refused_delete_raises_the_glue_dialog() {
use crate::glue::dialog::{DialogKind, GlueDialog};
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_message::<CharActionResultMessage>()
.init_resource::<GlueDialog>()
.add_systems(Update, delete_result);
app.world_mut().write_message(CharActionResultMessage {
action: CharAction::Delete,
code: benilla_protocol::messages::CHAR_DELETE_SUCCESS,
});
app.update();
assert!(
!app.world().resource::<GlueDialog>().is_open(),
"a success is answered by the roster, not a dialog"
);
app.world_mut().write_message(CharActionResultMessage {
action: CharAction::Delete,
code: 0x3A,
});
app.update();
let dialog = app.world().resource::<GlueDialog>();
assert_eq!(dialog.kind, Some(DialogKind::Error), "the OKAY dialog");
// No GlueStrings in this App: the fallback literal. The shipped sentence is asserted
// against the real chain below.
assert_eq!(dialog.text, "Character deletion failed");
}
/// The delete refusals resolve to the sentences 1.12 ships, off the player's own chain — and
/// every key named is a REAL key, so a matching fallback cannot hide a missing one. Skips
/// without client data.
#[test]
fn every_delete_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);
assert_eq!(char_delete_refusal_text(&strings, 0x39), None);
assert_eq!(
char_delete_refusal_text(&strings, 0x3A),
Some("Character deletion failed")
);
assert_eq!(
char_delete_refusal_text(&strings, 0x3B),
Some(
"Your character is currently locked as part of the paid character transfer \
process."
)
);
let map = strings.into_map();
for key in [
"CHAR_DELETE_IN_PROGRESS",
"CHAR_DELETE_FAILED",
"CHAR_DELETE_FAILED_LOCKED_FOR_TRANSFER",
] {
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
+10 -12
View File
@@ -1,6 +1,5 @@
//! **Chat bubbles** — `CGChatBubbleFrame` (wow-re `object-layer/scratch/chat-bubble.md`,
//! §5-verified 2026-07-11; decision 0288's phase-9 tail, landed by decision 0598): the
//! over-the-head speech bubble a chat line spawns, a **2-D overlay** like the V-plate
//! **Chat bubbles** — `CGChatBubbleFrame` (decision 0288's phase-9 tail, landed by decision
//! 0598): the over-the-head speech bubble a chat line spawns, a **2-D overlay** like the V-plate
//! ([`crate::vplates`]) and unlike the world-pass overhead *names* ([`crate::nameplates`]).
//!
//! The pinned law, transcribed:
@@ -31,15 +30,14 @@
//! the **MD20 header image**, i.e. the **Stand sequence CAaBox's Z extent**, a file constant
//! with no bone matrix anywhere in its call tree — and the scaled product is **latched** at
//! `bubble+0x354` behind a parity guard, so it is queried exactly **once per chat line**
//! ([`crate::entities::StandBoxHeight`], wow-re's anchor cross-check 2026-08-17; 1406).
//! ([`crate::entities::StandBoxHeight`], 1406).
//! - **Stacking** (`0x4b1060`, the per-frame pass over ALL live bubbles — one caller, `0x4817a3`):
//! each bubble is its own frame and its frame LEVEL is restamped every frame from the CAMERA's
//! distance to the speaker. The list re-sorts farthest-first (`0x4b1360`, comparing
//! `[bubble+0x350]` = |cam − unit|², written at `0x4b12c0`), then a head→tail walk stamps
//! `SetFrameLevel(2), (3), (4)…` (`0x4b1309`). Level outranks draw layer in the client's total
//! order, so overlapping bubbles stack as WHOLE cards with the nearest speaker's on top —
//! nothing interleaves. ([`level_z`]; benilla read the bytes for this one, 1504 — wow-re's
//! `chat-bubble.md` covers the spawn/geometry/anchor and never reached the manager tick.)
//! nothing interleaves. ([`level_z`]; benilla read the bytes for this one, 1504.)
//!
//! Named divergences (all deliberate):
//! - ~~**`ChatBubblesParty` defaults ON**~~ — **no longer a divergence (1804).** It shipped `"1"`
@@ -48,8 +46,8 @@
//! [`crate::vplates::VPlateMode`], whose enemy plates were the same shape of pin.
//! - **The v1 kind set is SAY/YELL/PARTY + monster say/yell.** The byte gate is
//! "sender resolves", not a type whitelist, which *implies* guild/officer/whisper/emote
//! bubbles too — but that category claim is INFERRED on an OPEN wire-type remap
//! (chat-bubble.md §1) and contradicts the remembered reference look, so the uncontested
//! bubbles too — but that category claim is inferred, resting on an open wire-type remap
//! (`0x49a870`), and contradicts the remembered reference look, so the uncontested
//! set ships and a capture can widen it ([`bubble_cvar`]).
//! - ~~**The anchor height is the posed overhead attachment**~~ — **REFUTED and removed (1406).**
//! This shipped as an INFERRED equivalence ("both are the head-region attachment height,
@@ -91,9 +89,9 @@ use benilla_world::view::WorldCamera;
/// they were a pair of `const bool` from 0598 until the options window had a page to put
/// them on, which is exactly the shape 1134 calls a row over a frozen gate.
///
/// **Both defaults are the binary's own** (`ChatBubbles` `"1"`, `ChatBubblesParty` `"0"` — wow-re
/// `object-layer/scratch/chat-bubble.md`). `party` shipped ON from 0598 to 1804 on the director's
/// `/p` ask; the row is on the Chat page, one click from where it was.
/// **Both defaults are the binary's own** (`ChatBubbles` `"1"`, `ChatBubblesParty` `"0"`).
/// `party` shipped ON from 0598 to 1804 on the director's `/p` ask; the row is on the Chat page,
/// one click from where it was.
#[derive(Resource)]
pub(crate) struct BubbleConfig {
/// `ChatBubbles` — say/yell and their monster variants.
@@ -136,7 +134,7 @@ const BORDER_FRAC: f32 = 16.0 / 1024.0;
/// over the frame's bottom edge piece: its art carries the border lines that make the seam read
/// continuous, and the reference gets the same result from its own layer key (the tail is an
/// ARTWORK texture, the text an ARTWORK font string, and a batch drains every texture before
/// every font string — wow-re `ui/scratch/draw-order-law.md` §4).
/// every font string — `0x76fb00`).
const Z_BG: u64 = 0;
const Z_EDGE: u64 = 1;
const Z_TAIL: u64 = 2;
+1 -1
View File
@@ -3,7 +3,7 @@
//!
//! Three consumers today and they are unrelated to each other — [`crate::ui_pet_book`] wants field
//! 4's pet name token, `UnitHasRelicSlot` wants field 16's relic flag, and
//! [`crate::spell_mods`] wants field 15's class spell-family — which is exactly why the
//! `crate::spell::mods` wants field 15's class spell-family — which is exactly why the
//! table does not live inside any of them. It used to live in the pet book, back when the pet
//! token was the only column anyone read.
//!
+14 -17
View File
@@ -9,8 +9,7 @@
//!
//! # What the reference does, and what we match
//!
//! Byte-verified in wow-re (the cinematic dispatch, 2026-08-29), and the reason each piece here is
//! shaped the way it is:
//! The reason each piece here is shaped the way it is:
//!
//! - **A trigger that arrives before the world is up is deferred, not dropped.** The reference
//! stashes the sequence id in a single-slot latch (`0xc4d75c`) whenever its world-load gate is
@@ -50,8 +49,7 @@
//! its *completion callback*: `0x4c0d10` builds a fullscreen opaque-black quad (`0xff000000`) and
//! latches what to run when it is fully up, so `CINEMATIC_START`, each shot advance and
//! `EndCinematic` all execute at full black and `0x4c1280` fades back in. Six sites, both edges,
//! always black, always 0.25 s (wow-re `ui/scratch/cinematic-camera-law.md` §3.7, a CORRECTION to
//! the earlier "deferred by a delay" reading). There is **no audio fade anywhere on this path**.
//! always black, always 0.25 s. There is **no audio fade anywhere on this path**.
//!
//! benilla plays and acks immediately. Building the picture is what would let the timing be
//! faithful too, so the two go together and neither is faked without the other — decision 1724
@@ -97,10 +95,10 @@ pub(crate) struct Cinematic {
/// is **holding at black** until the world around the body is presentable again.
///
/// This is the reference's `0x48f080` — EndCinematic's forced terrain load back around the
/// followed unit, which runs at full black and *blocks* (law §8.2, and §8.4: no loading cover
/// is raised anywhere on this path). benilla streams asynchronously and cannot block, so it
/// holds the black the reference was already showing for as long as that load would have
/// taken. Same thing on screen; ours keeps rendering.
/// followed unit, which runs at full black and *blocks* (no loading cover is raised anywhere on
/// this path: its raise, `0x406800`, has no caller there). benilla streams asynchronously and
/// cannot block, so it holds the black the reference was already showing for as long as that
/// load would have taken. Same thing on screen; ours keeps rendering.
///
/// The value is the seconds of black left to spend waiting. **Black is never held forever:**
/// if the world does not come back inside the budget the fade comes in anyway, and the loading
@@ -321,8 +319,7 @@ fn drive_letterbox(
// `UiHidden` goes back to meaning only what ALT-Z means.
// **The hardware cursor goes with it** — `0x58b590(0)` at StartCinematic, `(1)` at
// EndCinematic and at the leave-world teardown (wow-re `ui/scratch/cinematic-camera-law.md`
// §3.4, the complete 10-site census of the cinematic state cell). Nothing else in benilla
// EndCinematic and at the leave-world teardown. Nothing else in benilla
// would: this flag is otherwise written only by the mouse-look session in `player::camera`,
// and `control` skips that whole branch while the view is detached, so the pointer the player
// arrived at character-select with simply sat on top of the fly-by.
@@ -541,8 +538,8 @@ const SETTLE_BUDGET: f32 = 8.0;
///
/// **The FOV is not touched at all**, and that is the correction decision 1711 landed. A fly-by is
/// rendered through the world camera's own optics, re-stamped every frame — the M2 camera record's
/// `fov` is written at model load and read by nothing on this path (wow-re
/// `ui/scratch/cinematic-camera-law.md`, a 24-site census; its one raw reader is reachable only
/// `fov` is written at model load (`0x70f336`) and read by nothing on this path (its one raw
/// reader, `0x7ac640`, is reachable only
/// from the portrait and `<Model>` frames). Feeding the authored 45 degrees through the reference's
/// own `theta_v = F / sqrt(aspect^2 + 1)` builder — which is otherwise right, and angle-space
/// division really is what `0x5c3cc0` does — rendered fifteen of the sixteen shipped shots at
@@ -965,11 +962,11 @@ mod ack_ledger {
/// **The end boundary holds the black until the world is back around the body.**
///
/// The reference's EndCinematic does a *blocking* terrain load at full black (`0x48f080`, law
/// §8.2) and raises no loading cover anywhere on the cinematic path (§8.4). benilla streams
/// asynchronously, so "block" is not available — but "keep showing the black the fade already put
/// up" is, and it produces the same thing on screen. Without it the fly-by ends into a loading
/// cover for half a second, measured, every time.
/// The reference's EndCinematic does a *blocking* terrain load at full black (`0x48f080`) and
/// raises no loading cover anywhere on the cinematic path (its raise, `0x406800`, has no caller
/// there). benilla streams asynchronously, so "block" is not available — but "keep showing the
/// black the fade already put up" is, and it produces the same thing on screen. Without it the
/// fly-by ends into a loading cover for half a second, measured, every time.
#[cfg(test)]
mod settling_under_black {
use super::*;
@@ -1,9 +1,9 @@
//! The combat-log **chat** arms (B297) — the second consumer of the packets
//! [`super::combat_log`] and [`super::combat`] already read for the floating numbers.
//! The combat log's **chat line** leg (B297) — one of the two consumers of every combat packet;
//! the floating number is [`super::text`]'s, and [`super`]'s handlers call this leg first.
//!
//! The split is the reference's own. `0x629b60` is the display dispatcher: one classification of
//! both endpoints, then a branch per outcome into a formatter that emits *text*. The floating
//! number comes off a different path entirely (`0x625010`, the world-anchored spawn), gated by
//! number comes off a different path entirely (`0x629d30`, which fires `COMBAT_TEXT_UPDATE`), gated by
//! different CVars, with a different source law — which is why our two live in different files
//! rather than in one arm with two tails. Everything here composes; the law it composes with is
//! [`crate::ui_chat::combat`].
@@ -25,11 +25,11 @@ use benilla_protocol::messages::{
use crate::ui_chat::combat::{self, Family, Fills, UnitClass};
use crate::ui_chat::ChatLog;
use super::super::{GuidIndex, ObjectStore, Reputations, SelfGuid};
use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid};
/// The classification inputs every line needs, bundled so seven arms do not each grow six
/// parameters. Assembled once per drain in [`super::apply_net_updates`].
pub(super) struct ChatCtx<'a> {
/// The classification inputs every line needs, bundled so seven legs do not each grow six
/// parameters. Assembled per packet by [`super`]'s handlers from [`super::Log`].
pub(crate) struct ChatCtx<'a> {
pub self_guid: &'a SelfGuid,
pub group: Option<&'a crate::ui_party::GroupState>,
pub index: &'a GuidIndex,
@@ -69,11 +69,11 @@ impl ChatCtx<'_> {
}
/// A spell's display name, or `None` when the reference would emit **no line at all** for this
/// spell (§5.5 of the §5 verdict). Two gates, both of which 1571 was missing:
/// spell (`0x62cd80`, like every spell formatter). Two gates, both of which 1571 was missing:
///
/// - **`Attributes & 0x180`** — `SPELL_ATTR_DO_NOT_DISPLAY | SPELL_ATTR_DO_NOT_LOG` (the mask
/// and the `SpellRec+0x18` offset are VERIFIED; the two enum names are wow-re's INFERRED
/// corroboration from vmangos). This is what keeps the invisible book-keeping spells every
/// and the `SpellRec+0x18` offset are the binary's; the enum names are inferred from vmangos
/// `SpellDefines.h:799-800`). This is what keeps the invisible book-keeping spells every
/// server casts constantly — proc triggers, aura tickers — out of the log entirely.
/// - **An empty localized name.** The reference gates on the name it is about to print, so a
/// row with no text in this locale produces silence rather than a sentence with a hole in it.
@@ -93,8 +93,8 @@ impl ChatCtx<'_> {
}
/// `0x6ea280 == 2` — **"this spell targets enemies"**, the predicate the chat-type stubs
/// `0x627d30`/`0x627d60` run to choose between a family's `…_DAMAGE` and `…_BUFF` rows
/// (§2.3). It reads the row, never the endpoints, which is why it lives beside
/// `0x627d30`/`0x627d60` run to choose between a family's `…_DAMAGE` and `…_BUFF` rows.
/// It reads the row, never the endpoints, which is why it lives beside
/// [`Self::spell_name`] and not beside the classifier.
///
/// A row the catalog cannot answer reads as **harmful**, which is not a claim about the
@@ -149,7 +149,7 @@ pub(super) fn attacker_state(
amount: i64::from(s.damage),
// **Only the landed-hit family grows a trailer**, and it is the only family in the whole
// block that can show GLANCING/CRUSHING/BLOCK: `0x628410`'s four call sites, and this is
// the one that passes a real `blocked` and a real `HitInfo` (§4.2). A dodge or a full
// the one that passes a real `blocked` and a real `HitInfo` (`0x629ee7`). A dodge or a full
// absorb prints no amount at all — the reference does not append to those lines.
trailers: landed.then_some(combat::Trailers {
absorbed: s.absorb,
@@ -260,7 +260,7 @@ pub(super) fn spell_damage_log(
return;
};
// Nothing through: the reference words it as the reason rather than as a zero, and the order
// of the three tests is its own — absorb, then BLOCK, then resist (§4.3).
// of the three tests is its own — absorb, then BLOCK, then resist (`0x62cd80`).
let family = if s.damage == 0 && s.absorb > 0 {
combat::SPELLLOGABSORB
} else if s.damage == 0 && s.blocked > 0 {
@@ -325,8 +325,8 @@ pub(super) fn spell_log_miss(
// which routes `0x62bc5a` to `0x62c140` — the damage-shield two-way selector — instead of the
// usual eight-row spell matrix. Its five other callers pass 0 and take the normal path.
//
// Whether that is deliberate or a 1.12 bug is **not derivable from the binary**, and wow-re
// says so rather than guessing. We reproduce the behaviour, because an addon filtering on
// Whether that is deliberate or a 1.12 bug is **not derivable from the binary**. We reproduce
// the behaviour, because an addon filtering on
// chat type has to see what the reference shows; the discriminator, if anyone wants it, is a
// live cast that misses and a look at which ChatFrame filter catches the line. 1571 sent these
// through `spell_kind` and had them land in `SPELL_*_DAMAGE`.
@@ -1214,7 +1214,7 @@ fn power_drain_line(
let family = leech_family(multiplier);
// Arm 6. Both stubs ask the SPELL, not the family: `0x627d30` is
// `call 0x6ea280; cmp eax,2; je -> 0x626be0 (damage) else 0x627820 (buff)` and `0x627d60` is
// its periodic twin over `0x627d80`/`0x6274a0` (§2.3 of wow-re's combat-log chat law). So a
// its periodic twin over `0x627d80`/`0x6274a0`. So a
// helpful drain files under `…_BUFF` and a harmful one under `…_DAMAGE`, which is a property
// of the row in `Spell.dbc` and not something either call site may hardcode — both of ours
// did, in opposite directions.
@@ -1355,7 +1355,7 @@ pub(super) fn environmental_damage_log(
///
/// The wire's `reputationListId` is `Faction.dbc`'s `rep_index`, not a faction id; the name comes
/// off the row that carries that index.
pub(super) fn faction_standing(
pub(crate) fn faction_standing(
deltas: &[(u32, i32)],
reputations: &Reputations,
factions: Option<&crate::target::ring::Factions>,
@@ -1433,7 +1433,7 @@ fn queue(
}
/// [`queue`] for a family whose `Named` slot still has to be looked up — an item entry or a unit
/// guid rides along and the drain resolves it, holding the line until it lands (§5.7).
/// guid rides along and the drain resolves it, holding the line until it lands (`0x6294b0`).
fn queue_named(
log: &mut ChatLog,
ctx: &ChatCtx,
@@ -1466,7 +1466,7 @@ fn queue_reported(
fills: Fills,
named: combat::Named,
) -> bool {
// **The gate is an OR over the two endpoints, not an AND** — the §5 verdict's own wording:
// **The gate is an OR over the two endpoints, not an AND** — `0x626630`'s own test:
// `dist²(player, src) < range(srcClass)²` **OR** `dist²(player, tgt) < range(tgtClass)²`. 1571
// required both and therefore dropped lines the client shows: your own pet (range 100000)
// fighting something 80 yards away is logged by the reference and was silent here.
+335
View File
@@ -0,0 +1,335 @@
//! **The combat log** — the reference's own handler cluster for the combat packets (in the net
//! handler table since 2323, moved out of the drain's `combat_chat` and `combat_log` arm files).
//! Every combat packet has two consumers, and the reference emits both from one handler: the
//! **chat line** ([`chat`], the `0x629b60` display dispatcher's text) and the **floating
//! number** with the portrait's `UNIT_COMBAT` flash ([`text`], `0x629d30` →
//! `COMBAT_TEXT_UPDATE`; `0x625010` is the melee blood spurt, not this). They are gated by different CVars and classify by different laws, which is why the
//! legs are two files; they answer one packet, which is why the handler is one — the line first,
//! the number second, as the drain's match ran them.
//!
//! Two packets have a third consumer: the completed swing and the environmental-damage log also
//! drive the animation layer, whose handlers ([`crate::creature_anim::net`]) are registered after
//! these, so a kind with two handlers runs the line, then the animation.
use benilla_protocol::messages::SpellOutcomeLog;
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use crate::combat_text::{CombatTextSpawn, DamageTextGates};
use crate::names::NameCache;
use crate::net::{
GuidIndex, NetCommands, NetHandlerApp, ObjectStore, Reputations, SelfGuid, ServerSoundMessage,
};
use crate::ui_chat::combat::{CombatLogRanges, LogPeriodicSpells};
use crate::ui_chat::ChatLog;
use crate::ui_unit::{CombatTextEvent, UnitCombatFeedback};
pub(crate) mod chat;
pub(crate) mod text;
/// What the chat line classifies against — [`chat::ChatCtx`]'s inputs, built per packet by
/// [`Ctx::ctx`]. Read-only, so a handler can hold it beside the mutable [`Sinks`].
#[derive(SystemParam)]
pub(crate) struct Ctx<'w> {
self_guid: Res<'w, SelfGuid>,
group: Res<'w, crate::ui_party::GroupState>,
index: Res<'w, GuidIndex>,
factions: Option<Res<'w, crate::target::ring::Factions>>,
reputations: Res<'w, Reputations>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
/// The eight display ranges.
ranges: Res<'w, CombatLogRanges>,
/// `CombatLogPeriodicSpells`.
periodic: Res<'w, LogPeriodicSpells>,
}
impl Ctx<'_> {
fn ctx(&self) -> chat::ChatCtx<'_> {
chat::ChatCtx {
self_guid: &self.self_guid,
group: Some(&self.group),
index: &self.index,
factions: self.factions.as_deref(),
reputations: &self.reputations,
spells: self.spells.as_deref(),
ranges: &self.ranges,
periodic: self.periodic.0,
}
}
}
/// What the two legs read from the world and write to: the object stores and poses both legs
/// resolve endpoints through, the chat log, the number's gates and its three sinks.
#[derive(SystemParam)]
pub(crate) struct Sinks<'w, 's> {
/// `CombatDamage` + the two `Pet*` sub-gates.
damage_text: Res<'w, DamageTextGates>,
stores: Query<'w, 's, &'static mut ObjectStore>,
poses: Query<'w, 's, &'static mut Transform>,
log: ResMut<'w, ChatLog>,
names: Res<'w, NameCache>,
net: Res<'w, NetCommands>,
text: MessageWriter<'w, CombatTextSpawn>,
feedback: MessageWriter<'w, UnitCombatFeedback>,
center: MessageWriter<'w, CombatTextEvent>,
}
/// The combat log's plugin — registration only; the two legs' state is the chat window's and the
/// combat text's. Added ahead of [`crate::creature_anim::CreatureAnimPlugin`] so the two kinds
/// both answer run the line before the animation.
pub(crate) struct CombatLogPlugin;
impl Plugin for CombatLogPlugin {
fn build(&self, app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::AttackerState, on_attacker_state)
.net_handler(K::SpellDamageLog, on_spell_damage_log)
.net_handler(K::PeriodicAuraLog, on_periodic_aura_log)
.net_handler(K::SpellHealLog, on_spell_heal_log)
.net_handler(K::SpellEnergizeLog, on_spell_energize_log)
.net_handler(K::DamageShield, on_damage_shield)
.net_handler(K::SpellLogMiss, on_spell_log_miss)
.net_handler(K::PartyKillLog, on_party_kill_log)
.net_handler(K::SpellInstaKillLog, on_spell_insta_kill_log)
.net_handler(K::ProcResist, on_spell_outcome_log)
.net_handler(K::SpellOrDamageImmune, on_spell_outcome_log)
.net_handler(K::SpellDispelLog, on_spell_dispel_log)
.net_handler(K::DispelFailed, on_dispel_failed)
.net_handler(K::EnchantmentLog, on_enchantment_log)
.net_handler(K::SpellLogExecute, on_spell_log_execute)
.net_handler(K::EnvironmentalDamageLog, on_environmental_damage_log)
.net_handler(K::XpGain, on_xp_gain)
.net_handler(K::ExplorationXp, on_exploration_xp)
.net_handler(K::LevelUp, on_level_up)
.net_handler(K::PvpCredit, on_pvp_credit);
}
}
fn on_attacker_state(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::AttackerState(s) = ev {
let ctx = c.ctx();
chat::attacker_state(s, &ctx, &l.stores, &l.poses, &mut l.log);
}
}
fn on_spell_damage_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::SpellDamageLog(s) = ev {
let ctx = c.ctx();
chat::spell_damage_log(s, &ctx, &l.stores, &l.poses, &mut l.log);
text::spell_damage_log(
s,
&c.index,
&c.self_guid,
&l.stores,
c.spells.as_deref(),
*l.damage_text,
&mut l.text,
&mut l.feedback,
&mut l.center,
);
}
}
/// **`CombatLogPeriodicSpells` gates the WHOLE packet body, and this handler is where that is
/// expressible.** The reference's read site `0x626dee` is the first thing the handler `0x626dd0`
/// does, and a zero jumps to the bare epilogue `0x6271b4`: no chat line, no floating tick number,
/// no periodic miss word. Gating inside either leg would model it as two filters; it is one gate
/// over both.
fn on_periodic_aura_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::PeriodicAuraLog(s) = ev {
if !c.periodic.0 {
return;
}
let ctx = c.ctx();
chat::periodic_aura_log(&s, &ctx, &l.stores, &l.poses, &mut l.log);
text::periodic_aura_log(
s,
&c.index,
&c.self_guid,
&l.stores,
c.spells.as_deref(),
*l.damage_text,
&mut l.text,
&mut l.feedback,
&mut l.center,
&l.names,
&l.net,
);
}
}
fn on_spell_heal_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::SpellHealLog(s) = ev {
let ctx = c.ctx();
chat::spell_heal_log(s, &ctx, &l.stores, &l.poses, &mut l.log);
text::spell_heal_log(
s,
&c.index,
&c.self_guid,
&mut l.feedback,
&mut l.center,
&l.names,
&l.net,
);
}
}
fn on_spell_energize_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::SpellEnergizeLog(s) = ev {
let ctx = c.ctx();
chat::spell_energize_log(s, &ctx, &l.stores, &l.poses, &mut l.log);
text::spell_energize_log(s, &c.self_guid, &mut l.center);
}
}
fn on_damage_shield(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::DamageShield(s) = ev {
let ctx = c.ctx();
chat::damage_shield(s, &ctx, &l.stores, &l.poses, &mut l.log);
text::damage_shield(
s,
&c.index,
&c.self_guid,
&l.stores,
*l.damage_text,
&mut l.text,
&mut l.feedback,
);
}
}
fn on_spell_log_miss(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::SpellLogMiss(s) = ev {
let ctx = c.ctx();
chat::spell_log_miss(&s, &ctx, &l.stores, &l.poses, &mut l.log);
text::spell_log_miss(
s,
&c.index,
&c.self_guid,
&l.stores,
c.spells.as_deref(),
*l.damage_text,
&mut l.text,
&mut l.feedback,
&mut l.center,
);
}
}
// ── the combat log's completeness block (1703): every one of these is chat-only — they carry
// no damage number, so unlike their neighbours above they have no floating-text leg to call ──
fn on_party_kill_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::PartyKillLog(k) = ev {
let ctx = c.ctx();
chat::party_kill_log(k, &ctx, &l.stores, &l.poses, &mut l.log);
}
}
fn on_spell_insta_kill_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::SpellInstaKillLog(k) = ev {
let ctx = c.ctx();
chat::spell_insta_kill_log(k, &ctx, &l.stores, &l.poses, &mut l.log);
}
}
fn on_spell_outcome_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
let (o, immune): (SpellOutcomeLog, bool) = match ev {
SessionEvent::ProcResist(o) => (o, false),
SessionEvent::SpellOrDamageImmune(o) => (o, true),
_ => return,
};
let ctx = c.ctx();
chat::spell_outcome_log(o, immune, &ctx, &l.stores, &l.poses, &mut l.log);
}
fn on_spell_dispel_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::SpellDispelLog(d) = ev {
let ctx = c.ctx();
chat::spell_dispel_log(&d, &ctx, &l.stores, &l.poses, &mut l.log);
}
}
fn on_dispel_failed(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::DispelFailed(d) = ev {
let ctx = c.ctx();
chat::dispel_failed(&d, &ctx, &l.stores, &l.poses, &mut l.log);
}
}
fn on_enchantment_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::EnchantmentLog(e) = ev {
let ctx = c.ctx();
chat::enchantment_log(e, &ctx, &l.stores, &l.poses, &mut l.log);
}
}
fn on_spell_log_execute(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::SpellLogExecute(x) = ev {
let ctx = c.ctx();
chat::spell_log_execute(&x, &ctx, &l.stores, &l.poses, &mut l.log);
}
}
fn on_environmental_damage_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::EnvironmentalDamageLog(e) = ev {
let ctx = c.ctx();
chat::environmental_damage_log(e, &ctx, &l.stores, &l.poses, &mut l.log);
}
}
fn on_xp_gain(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
if let SessionEvent::XpGain(x) = ev {
text::xp_gain(x, &c.index, &c.self_guid, &mut l.text, &mut l.log);
}
}
fn on_exploration_xp(
In(ev): In<SessionEvent>,
c: Ctx,
mut l: Sinks,
area_table: Option<Res<crate::area::AreaTableRes>>,
exploration_sounds: Option<Res<crate::sound::ExplorationSounds>>,
mut server_sounds: MessageWriter<ServerSoundMessage>,
) {
if let SessionEvent::ExplorationXp(x) = ev {
text::exploration_xp(
x,
area_table.as_deref(),
exploration_sounds.as_deref(),
&c.index,
&c.self_guid,
&l.stores,
&mut server_sounds,
&mut l.log,
);
}
}
fn on_level_up(In(ev): In<SessionEvent>, mut l: Sinks) {
if let SessionEvent::LevelUp(lv) = ev {
text::level_up(lv, &mut l.log);
}
}
/// An honor award (decision 1512 — the arc's other inbound message, the inspect reply, is
/// `ui_honor`'s own handler): the combat-log line (name-resolved, so it queues) and the floating
/// number, which are two different surfaces of one packet and are both the reference's. A
/// DISHONORABLE kill arrives here too, carrying NEGATIVE honor — the floating text takes it
/// signed, because the shipped `COMBAT_TEXT_HONOR_GAINED` handler prefixes a "+" only when the
/// number is positive and therefore already expects the other case.
fn on_pvp_credit(In(ev): In<SessionEvent>, mut l: Sinks) {
if let SessionEvent::PvpCredit(credit) = ev {
l.log.push_pvp_credit(
credit.honor,
credit.victim_guid,
u8::try_from(credit.victim_rank).unwrap_or(0),
);
l.center.write(CombatTextEvent {
message_type: "HONOR_GAINED",
data: Some(credit.honor.to_string()),
extra: None,
});
}
}
@@ -1,6 +1,6 @@
//! Combat-log SMSG arm bodies for [`super::apply_net_updates`]'s dispatch match — the floating
//! combat-text **spawn table** (decision 0137 phase 2; wow-re `worldtext-spawn-and-law.md`). One
//! `pub(super)` fn per arm, mirroring the client's handler → emitter structure: each classifies
//! The combat log's **floating number** leg — the combat-text **spawn table** (decision 0137
//! phase 2; the callers of `SubmitWorldText 0x6c7840`). One fn per packet, mirroring the client's
//! handler → emitter structure; [`super`]'s handlers call it after the chat line: each classifies
//! the damage **source** (the color law's `K` — self / owned-by-me / anything else SUPPRESSED),
//! resolves the outcome **recipient**, applies Gate A (self-anchored damage text is
//! unconditionally suppressed — outgoing damage floats over the victim, incoming never floats
@@ -28,7 +28,7 @@ use crate::names::NameCache;
use crate::ui_chat::ChatLog;
use crate::ui_unit::{CombatTextEvent, UnitCombatFeedback};
use super::super::{GuidIndex, NetCommands, ObjectStore, SelfGuid};
use crate::net::{GuidIndex, NetCommands, ObjectStore, SelfGuid};
/// Gate A + anchor resolution: the recipient's entity, unless the recipient is us (`0x607140`/
/// `0x6128b0` compare the anchor guid to the active-player cache and return before submitting).
@@ -43,7 +43,7 @@ fn gated_anchor(guid: u64, index: &GuidIndex, self_guid: &SelfGuid) -> Option<En
/// against the active player — `Player` (me), `Pet` (a unit whose Summoned/CreatedBy guid is me:
/// pet, guardian, totem), or `None` = every other class, which SUPPRESSES the emit entirely
/// (another unit's damage is never drawn; the real emitter returns before submitting).
pub(super) fn classify_source(
pub(crate) fn classify_source(
guid: u64,
index: &GuidIndex,
self_guid: &SelfGuid,
@@ -73,8 +73,8 @@ pub(super) fn melee_styled(spells: Option<&crate::ui_action::Spells>, spell_id:
/// The spell arms' shared `UNIT_COMBAT` split: a landed amount is `WOUND` (`CRITICAL` descriptor
/// on crit), zero damage degrades to the full-`ABSORB`/`RESIST` descriptor, else nothing (a clean
/// spell miss arrives via `SMSG_SPELLLOGMISS` instead). PROVISIONAL string mapping pending the
/// wow-re UNIT_COMBAT emission pin (decision 0576).
/// spell miss arrives via `SMSG_SPELLLOGMISS` instead). PROVISIONAL string mapping until the
/// reference's UNIT_COMBAT emission is pinned down (decision 0576).
fn spell_feedback(
damage: u32,
absorb: u32,
@@ -92,12 +92,11 @@ fn spell_feedback(
}
}
/// Spell packet → the center text's messageType (§5-verified against the emission law, wow-re
/// `combat-text-update-emission-law.md`): landed damage is ALWAYS `SPELL_DAMAGE` — **a spell
/// Spell packet → the center text's messageType: landed damage is ALWAYS `SPELL_DAMAGE` — **a spell
/// crit does NOT fire DAMAGE_CRIT** (no crit-distinct type exists for spell damage; the `0x62cd80`
/// emitter fires 29 regardless, crit changes only the chat template) — zero damage the
/// `SPELL_ABSORBED`/`SPELL_RESISTED` word. Spell-side partial trailers stay a named residual (the
/// verdict pinned the melee helper-B partials; the spell emitter's partial path is unread).
/// melee partials go through helper B `0x6268f0`; the spell emitter's partial path is unread).
fn spell_center_text(
damage: u32,
absorb: u32,
@@ -114,13 +113,13 @@ fn spell_center_text(
}
}
/// Melee packet → the center text's messageType + args (§5-verified emission shape, wow-re
/// `combat-text-update-emission-law.md`; fired **synchronously at packet receive** — the
/// impact-keyframe deferral belongs to the worldtext/UNIT_COMBAT victim dispatch, NOT this Lua
/// event). Helper-B partials CONFIRMED: a landed hit with a partial block/absorb/resist fires
/// the word type with `(damage, partial)` — the addon renders `"25 (10 blocked)"`. The
/// partial-vs-crit precedence when both apply is unpinned — partials win here, flagged in 0580.
pub(super) fn melee_center_text(
/// Melee packet → the center text's messageType + args (`0x629d30`; fired **synchronously at
/// packet receive** — the impact-keyframe deferral belongs to the worldtext/UNIT_COMBAT victim
/// dispatch, NOT this Lua event). Helper-B partials CONFIRMED: a landed hit with a partial
/// block/absorb/resist fires the word type with `(damage, partial)` — the addon renders
/// `"25 (10 blocked)"`. The partial-vs-crit precedence when both apply is unpinned — partials
/// win here, flagged in 0580.
pub(crate) fn melee_center_text(
hit_info: u32,
victim_state: u32,
damage: u32,
@@ -334,9 +333,8 @@ pub(super) fn periodic_aura_log(
(action, flags, amount, school)
}
PeriodicTick::Heal { amount } => ("HEAL", "", amount, 0),
// NO ENERGIZE: 5875 never emits it (the string is absent binary-wide —
// §5-verified, wow-re `unit-combat-event-law.md`); the power gain reaches the
// center text only.
// NO ENERGIZE: 5875 never emits it (the string is absent binary-wide); the power
// gain reaches the center text only.
PeriodicTick::Energize { .. } | PeriodicTick::ManaLeech { .. } => continue,
};
feedback.write(UnitCombatFeedback {
@@ -461,8 +459,7 @@ pub(super) fn spell_heal_log(
/// `SMSG_SPELLENERGIZELOG` → the center text's `MANA`/`RAGE`/`FOCUS`/`ENERGY` line (self-only).
/// NO `UNIT_COMBAT` fires here: the 5875 engine has no ENERGIZE emission — the string is absent
/// from the whole binary (§5-verified, wow-re `unit-combat-event-law.md`; the shipped
/// CombatFeedback.lua ENERGIZE arm is dead code in 1.12).
/// from the whole binary (the shipped CombatFeedback.lua ENERGIZE arm is dead code in 1.12).
///
/// The amount is the **displayed** figure: the handler `0x5e8a90` divides by
/// `0x6e7130(powerType)` at `0x5e8af3` and hands that one number to the COMBAT_TEXT push
@@ -605,8 +602,8 @@ pub(super) fn xp_gain(
}
/// `SMSG_EXPLORATION_EXPERIENCE` → the discovery announcement (decision 0828; surfaces and
/// sound byte-verified by the 0829 RE, wow-re `system/net/net.md`). Three legs, mirroring the
/// real handler's case body `[0x5e41d2, 0x5e42bb]`:
/// sound from the reference, 0829). Three legs, mirroring the real handler's case body
/// `[0x5e41d2, 0x5e42bb]`:
/// - the ERR_ZONE_EXPLORED toast + (xp > 0) the ERR_ZONE_EXPLORED_XP chat line, queued via
/// [`ChatLog::push_exploration`] — the area name is the packet's `AreaTable.dbc` row id
/// resolved through the shared catalog; a miss (no catalog, or an id the DBC doesn't know)
@@ -623,7 +620,7 @@ pub(super) fn exploration_xp(
index: &GuidIndex,
self_guid: &SelfGuid,
stores: &Query<&mut ObjectStore>,
sound_out: &mut MessageWriter<super::super::ServerSoundMessage>,
sound_out: &mut MessageWriter<crate::net::ServerSoundMessage>,
chat_log: &mut ChatLog,
) {
let race = self_guid
@@ -634,8 +631,8 @@ pub(super) fn exploration_xp(
.and_then(|s| s.0.unit_race());
let kit = race.and_then(|r| sounds.and_then(|s| s.0.kit(u32::from(r))));
if let Some(kit) = kit {
sound_out.write(super::super::ServerSoundMessage {
kind: super::super::ServerSoundKind::Sound2d,
sound_out.write(crate::net::ServerSoundMessage {
kind: crate::net::ServerSoundKind::Sound2d,
sound_id: kit,
source: None,
});
@@ -658,10 +655,9 @@ pub(super) fn exploration_xp(
}
/// `SMSG_LEVELUP_INFO` → the ding's chat lines (decision 0304). The talent-count arg is not on the
/// wire — the client computes `(newLevel >= 10) ? 1 : 0` (byte-verified `0x5e407c`, wow-re
/// levelup-ding.md — the 0305 fold-back), exactly this. The ding's VISUAL is deliberately absent
/// here: it rides the UNIT_FIELD_LEVEL change-watcher (`entities` spell_fx::level_up_flash), never
/// this packet.
/// wire — the client computes `(newLevel >= 10) ? 1 : 0` (`0x5e407c`, the 0305 fold-back), exactly
/// this. The ding's VISUAL is deliberately absent here: it rides the UNIT_FIELD_LEVEL
/// change-watcher (`entities` spell_fx::level_up_flash), never this packet.
pub(super) fn level_up(l: LevelUpInfo, chat_log: &mut ChatLog) {
let talent_points = u32::from(l.level >= 10);
// Park the raw tuple for `ui_unit`'s `PLAYER_LEVEL_UP`, whose nine args the reference's own
+23 -25
View File
@@ -118,8 +118,7 @@ pub(crate) const COLOR_PET_MELEE_ORANGE: u32 = 0xFFFF_8400;
///
/// 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
/// `0x6128b0`. 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.
///
@@ -132,7 +131,7 @@ pub(crate) const COLOR_PET_MELEE_ORANGE: u32 = 0xFFFF_8400;
/// **`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.
/// coloured orange. Which spell ids carry it is an open question.
///
/// 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.
@@ -267,18 +266,17 @@ fn round_px(t: f64) -> f32 {
/// The composed size law (module doc): category value `v` → on-screen pixel height. One gx unit
/// is the **screen diagonal** `√(W²+H²)`: the screencoord device space spans `[0,G44]×[0,G48]`
/// with `G44 = s/√(s²+1)`, `G48 = 1/√(s²+1)` (`s = W/H` — the live globals `0x832a44/48`), so
/// `v/G48 × H = v·√(W²+H²)`. The first reading hardcoded G48's **4:3 value** (0.6 — the
/// resolution the difftest ran at), which under-sizes ~22% at 16:9 — the director's "damage
/// numbers should be 1–2 sizes bigger", one root cause with the small nameplates (wow-re
/// `nameplate-vkey.md` §8 Q4). The round is the gx px law ([`round_px`]); constant with unit
/// distance.
/// `v/G48 × H = v·√(W²+H²)`. The first reading hardcoded G48's **4:3 value** (0.6), which
/// under-sizes ~22% at 16:9 — the director's "damage numbers should be 1–2 sizes bigger", one
/// root cause with the small nameplates. The round is the gx px law ([`round_px`]); constant
/// with unit distance.
pub(super) fn text_px(v: f32, viewport: Vec2) -> f32 {
round_px(f64::from(v) * f64::from(viewport.x).hypot(f64::from(viewport.y)))
}
/// The shadow's offset — the verified static at `0xce8804` (`{0.002, 0.002}`, init `0x6c7c20`):
/// a **viewport fraction**, resolved per-axis and integer-rounded at draw (module doc; wow-re
/// `worldtext-shadow-render-law.md`, TU-A — which corrected our first `× diagonal` reading: the
/// The shadow's offset — the static at `0xce8804` (`{0.002, 0.002}`, init `0x6c7c20`):
/// a **viewport fraction**, resolved per-axis and integer-rounded at draw (module doc; `0x5c8710`,
/// not our first `× diagonal` reading: the
/// `√(W²+H²)` lives only in the unrelated Lua `GetScreenWidth/Height` path).
const SHADOW_OFFSET_FRAC: f32 = 0.002;
@@ -293,9 +291,9 @@ pub(super) fn shadow_offset_px(viewport: Vec2) -> Vec2 {
}
/// The anti-overlap CLAIM box (full width × height, px) — the ref's measured block under its
/// own units quirk (wow-re `worldtext-measured-block-wh-law.md`, §5 b2d59e6e): `0x6c81a0`
/// own units quirk: `0x6c81a0`
/// stores the halves as SCREEN FRACTIONS — height = the raw size value ÷ G48 verbatim (single
/// line: no line gap, and the shadow-Y term is a byte-verified dead store), width = the advance
/// line: no line gap, and the shadow-Y term is a dead store), width = the advance
/// sum ÷ screen width plus a `round(0.002·diag)` pen seed — and `0x6c7cc0` then spends those
/// fractions as DDC lengths in the solver rect. A fraction in a DDC slot inflates by diag/dim
/// (1/G48 ≈ 1.667× tall, 1/G44 ≈ 1.25× wide at 4:3): the reference's generous,
@@ -310,8 +308,8 @@ pub(super) fn claimed_box_px(ink_w: f32, size_value: f32, viewport: Vec2) -> Vec
)
}
/// The text and RENDERED shadow alpha bytes at `elapsed_ms` — `time_alpha_fade 0x6c82e0`'s two
/// lanes (§5-verified constants 255.0 / 127.0) composed through the store seam (module doc, the
/// The text and RENDERED shadow alpha bytes at `elapsed_ms` — the alpha fade `0x6c82e0`'s two
/// lanes (constants 255.0 / 127.0) composed through the store seam (module doc, the
/// STORE law): the shadow's stored alpha is **`min(shadow lane, text alpha)`** (`SetShadowColor
/// 0x5cd650`, font node — SetColor runs first each tick, so `mainA` is the fresh text byte).
/// Branch order is the client's: fade-in first (below fade-in-end the ramp arm wins even when
@@ -342,7 +340,7 @@ pub(super) fn fade_alpha(cat: &Category, elapsed_ms: f32) -> (u8, u8) {
(text, shadow.min(text))
}
/// The category's scale value at normalized life `t` (`keyframe_interp 0x6c80b0`): category 2 runs
/// The category's scale value at normalized life `t` (`0x6c80b0`): category 2 runs
/// the crit-pop keyframes × `valueHi`; every other row is the affine `lo + (hi − lo)·t` (constant,
/// since `lo == hi` outside the crit row). Floor 0.001, as the client clamps.
pub(super) fn scale_value(category: u8, t: f32) -> f32 {
@@ -378,9 +376,9 @@ mod tests {
/// The composed size law (`v · diagonal` → `ScreenToPixelHeight`): the exact pixel heights
/// at the 1024×768 reference window (diag 1280 — where the old `/0.6 × H` law coincides),
/// the round-half-away behavior, and the ASPECT correction (the whole point of the §8 Q4
/// re-pin: a 16:9 window sizes by ITS diagonal, not the 4:3 constant).
/// The claimed-box anchors from wow-re `worldtext-measured-block-wh-law.md` (b2d59e6e) at
/// the round-half-away behavior, and the ASPECT correction (the whole point: a 16:9 window
/// sizes by ITS diagonal through the live G48 `0x832a48`, not the 4:3 constant).
/// The claimed-box anchors (`0x6c81a0`) at
/// 1024×768: the box the solver sees runs 1/G48 taller than the glyph render — 39.1 px for
/// the 23 px steady number, 58.7 px for the 35 px crit settle — and 1/G44 (1.25×) wider
/// than ink + the `round(0.002·diag)` pen seed.
@@ -413,18 +411,18 @@ mod tests {
assert_eq!(text_px(scale_value(0, 0.5), ref43), 23.0);
}
/// The shadow offset law (`worldtext-shadow-render-law.md` TU-A): a per-axis viewport
/// The shadow offset law (`0x5c8710`): a per-axis viewport
/// fraction, gx-rounded — NOT the isotropic diagonal (which overstated the vertical ~2.2×
/// at 16:9 and grew with resolution).
#[test]
fn shadow_offset_is_a_per_axis_viewport_fraction() {
// The verdict's own example: 1920×1080 → {round(3.84), round(2.16)} = {4, 2}.
// 1920×1080 → {round(3.84), round(2.16)} = {4, 2}.
assert_eq!(
shadow_offset_px(Vec2::new(1920.0, 1080.0)),
Vec2::new(4.0, 2.0)
);
// The 4:3 reference window: {round(2.048), round(1.536)} = {2, 2} — the old note's
// "~2.6 px" diagonal read never rendered.
// The 4:3 reference window: {round(2.048), round(1.536)} = {2, 2} — the "~2.6 px"
// diagonal read never rendered.
assert_eq!(
shadow_offset_px(Vec2::new(1024.0, 768.0)),
Vec2::new(2.0, 2.0)
@@ -442,9 +440,9 @@ mod tests {
assert_eq!(CATEGORIES[5].color, 0xFFE0_CA0A);
}
/// `time_alpha_fade 0x6c82e0` at the pinned points of row 0 (in 150 / out 760 / dur 1500),
/// The alpha fade `0x6c82e0` at the pinned points of row 0 (in 150 / out 760 / dur 1500),
/// composed through the store seam (rendered values): fade-in ramps 255·t / 127·t over the
/// DURATION (so the fade-in boundary is a step to 255, the byte-verified pop-in), the
/// DURATION (so the fade-in boundary is a step to 255, the pop-in), the
/// plateau is the unconditional `(0xFF, 0x7F)`, the fade-out drops the text
/// `255 − clamp(255·u)` — and the SHADOW's raw `[128, 255]` inversion is min-capped to the
/// text alpha by the `0x5cd650` store, so it steps to ~0xFF at fade-out start and then
+21 -27
View File
@@ -1,5 +1,4 @@
//! **Floating combat text** (decision 0137 phase 2) — the client's WORLDTEXTSTRING engine
//! (wow-re `playername/scratch/worldtext-spawn-and-law.md`, §5-verified 2026-07-05).
//! **Floating combat text** (decision 0137 phase 2) — the client's WORLDTEXTSTRING engine.
//!
//! The law, transcribed: a combat-log SMSG's handler picks a **category 0–5** and submits a string
//! over the damage/outcome **recipient**; the text is **world-anchored** — the unit's OVERHEAD
@@ -14,10 +13,9 @@
//! head; the XP emitter (category 4) is self-anchored by design and skips the gate. Heals and
//! energize NEVER float in 5875 (chat log only; there is no `CombatHealing` cvar).
//!
//! **The COLOR law** (wow-re `playername/scratch/combattext-color-law.md`, §5-verified
//! 2026-07-06 — the director's "melee vs spell differ" CONFIRMED): the emitters key two bits —
//! `B` (record NULL ⇒ melee, or **AttributesEx3 bit-15** — `SpellRec+0x24`'s word; the note's
//! "+0x25 sign" byte) and `K` (the `0x5efea0` **source-ownership** class: self / owned-by-me /
//! **The COLOR law** (the director's "melee vs spell differ" CONFIRMED): the emitters key two bits
//! — `B` (record NULL ⇒ melee, or **AttributesEx3 bit-15** — `SpellRec+0x24`'s word, tested as the
//! sign of byte `+0x25`) and `K` (the `0x5efea0` **source-ownership** class: self / owned-by-me /
//! other). Master gate: the CombatDamage cvar. Self melee → **WHITE** (NULL override ⇒ the row
//! default); self spell/periodic → **GOLD** `0xFFFFDE00`; pet melee → **ORANGE** `0xFFFF8400`
//! (PetMeleeDamage cvar); pet spell → GOLD (PetSpellDamage cvar); **any OTHER source is
@@ -43,14 +41,14 @@
//! reads "Resist" when the ball lands, a missed Sinister Strike at packet receive.
//! [`missile_miss_text`] is that arrival half, fed by [`crate::entities::MissileMiss`].
//!
//! **The render geometry** (wow-re `worldtext-geometry-law.md`, §5-verified, 9af65294 — zero free
//! **The render geometry** (zero free
//! parameters; the anchor-seat half corrected here, see the seating comment in
//! [`float_combat_text`]): the rise is added to **world z before projection**; the block is
//! **h-centered with its bottom at** the projected point (rising above it), clamped on-screen —
//! but only once the projector has ACCEPTED the point at all: a number whose anchor lands behind
//! the camera or outside the viewport is **destroyed and its slot released**
//! (`6c7d9d` → `6c6dda` → `0x6c86a0`; wow-re `object-layer/scratch/nameplate-offscreen-cull.md`,
//! §5-VERIFIED 2026-08-15), never clamped to a border and never claiming a bucket-1 rect. Ours
//! (`6c7d9d` → `6c6dda` → `0x6c86a0`),
//! never clamped to a border and never claiming a bucket-1 rect. Ours
//! clamped, which put another unit's numbers on the screen edge — the worldtext half of 1341's
//! phantom plates, and the half that record's §7 wrongly called byte-attested;
//! the on-screen size is
@@ -58,8 +56,8 @@
//! and composes as `px = round_half_away(v × √(W²+H²))` — `v` the category's interpolated scale
//! value, the diagonal the gx screencoord unit (`0x832a44/48` hold the live aspect basis; the
//! first reading hardcoded its 4:3 value 0.6 — see [`text_px`]), the round being the gx
//! `ScreenToPixelHeight` law (`0x5c6fa0`, wow-re `crates/font/src/screen_pixel.rs`, bit-exact
//! difftested). At 1024×768 (diag 1280): normal number 23 px, crit settles 35 px, pop peaks ~70 px.
//! `ScreenToPixelHeight` law (`0x5c6fa0`). At 1024×768 (diag 1280): normal number 23 px, crit
//! settles 35 px, pop peaks ~70 px.
//! The font is **whatever the Lua global `DAMAGE_TEXT_FONT` holds** when `0x6c8470` reads it —
//! see [`DamageTextFont`] for the binding law (a plain `lua_gettable`, evaluated once, at the tail
//! of the world-entry UI load, i.e. after every addon's `ADDON_LOADED`). `Fonts\FRIZQT__.TTF` is
@@ -68,9 +66,8 @@
//! NULL handle and the combat text simply does not draw. Created flags 0 (`6c8493 xor edx,edx` —
//! not `6c8498`, which is `mov ecx,esi`): no outline.
//!
//! **The ALPHA + SHADOW law** (`time_alpha_fade 0x6c82e0` — wow-re
//! `playername/scratch/worldtext-alpha-shadow-law.md`, §5 pair + the emulated bit-exact difftest,
//! 2026-07-12; the director's "XP text less visible than ref"): the per-tick fade REPLACES the
//! **The ALPHA + SHADOW law** (the alpha fade `0x6c82e0`; the director's "XP text less visible
//! than ref"): the per-tick fade REPLACES the
//! live color's alpha byte (`mov [obj+0x23], bl` — the ARGB high byte), it never multiplies. The
//! plateau between fade-in end and fade-out start is an **unconditional `(0xFF, 0x7F)`** — so the
//! config table's packed alpha (row 4's `0x80`) is dead data, overwritten before it ever renders:
@@ -85,9 +82,9 @@
//! black, `(al<<24)|0` → `SetShadowColor 0x5c27a0` with the static offset `0xce8804 =
//! {0.002, 0.002}` (init `0x6c7c20`), every category, unconditionally.
//!
//! **The STORE seam caps the shadow** (font node `outline-bake-tint.md` §5, byte-verified):
//! **The STORE seam caps the shadow**:
//! `SetShadowColor`'s persistent store `0x5cd650` writes the shadow colour's alpha byte as
//! **`min(shadowA, mainA)`** — and `time_alpha_fade` calls SetColor (text) *then* SetShadowColor
//! **`min(shadowA, mainA)`** — and the alpha fade calls SetColor (text) *then* SetShadowColor
//! every tick, so the RENDERED shadow alpha is `min(shadow lane, text alpha)`. The fade-out
//! lane's byte-true `[128, 255]` inversion is dead past the text's own value: the rendered
//! shadow steps up to ~0xFF at fade-out start, then tracks the text alpha down to **0** — it
@@ -96,13 +93,11 @@
//! fraction**, converted per-axis at draw (`0x5c8710` → `ScreenToPixelWidth 0x5c7010` ×W /
//! `ScreenToPixelHeight 0x5c6fa0` ×H, each `+0.5`-truncate rounded): `offset_px =
//! {round(0.002·W), round(0.002·H)}`, drawn **down-right** (the Y-down ortho), shadow pass
//! before main — the whole as-rendered law is wow-re
//! `playername/scratch/worldtext-shadow-render-law.md` (§5 three pairs, 2026-07-13).
//! before main.
//!
//! **The anti-overlap push IS real** — decision 0367, superseding 0363's "none exists" (that
//! verdict was true of the worldtext code and wrong about the system: the push lives in the
//! shared `UIUtil\SmartScreenRect` draw-time solver, wow-re
//! `ui/scratch/smartscreenrect-solver-law.md`, §5 2026-07-13). Every frame each live string
//! shared `UIUtil\SmartScreenRect` draw-time solver). Every frame each live string
//! submits its desired rect — the clamped projected center ± its **crit-pop-scaled** measured
//! half-extents — to claim **bucket 1** ([`crate::smart_rect`]), gets relocated off the strings
//! already seated this frame, and draws from the returned rect (`0x6c7cc0` → `0x509520`,
@@ -187,7 +182,7 @@ pub(crate) struct WorldTexts(Vec<WorldText>);
/// pointer is a borrowed `const char*` into the Lua TString, consumed before the call returns.
/// `0x6c847c` is the **only** instruction in the image that reads this global.
///
/// **When** is the whole finding. `0x6c8470` is not CRT init, as three wow-re notes had it — it
/// **When** is the whole finding. `0x6c8470` is not CRT init — it
/// runs from `0x401570 + 0x1620`, *after* the UI load `0x401602`, so after FrameXML has run
/// `Fonts.xml` and after every non-LoadOnDemand addon's `ADDON_LOADED`. That is exactly where
/// MikScrollingBattleText (`MikScrollingBattleText.lua:255`) and pfUI (`pfUI.lua:179`) assign it,
@@ -341,8 +336,7 @@ pub(crate) fn float_combat_text(
order.sort_by_key(|&i| (texts.0[i].anchor, std::cmp::Reverse(texts.0[i].slot)));
// A number whose anchor fails the projector's accept verdict is **destroyed, and its slot
// released** (`6c7d9d` → `6c6dda` → `0x6c86a0`) — not skipped for a frame, and never clamped
// to a border (wow-re `object-layer/scratch/nameplate-offscreen-cull.md`, §5-VERIFIED
// 2026-08-15). Collected here and reaped after the walk, since the walk holds the list.
// to a border. Collected here and reaped after the walk, since the walk holds the list.
let mut culled: Vec<usize> = Vec::new();
for i in order {
let t = &texts.0[i];
@@ -402,7 +396,7 @@ pub(crate) fn float_combat_text(
// half-extents, run it through claim bucket 1 — normalize → solve → clamp → claim
// ([`crate::smart_rect`]) — and place the string at the SOLVED rect's center. The ink
// then sits h-centered with its BOTTOM at that point: the string's own justify pair at
// creation (`6c8254 push 0x2` in `string_measure_layout`'s `GxuFontCreateString` —
// creation (`6c8254 push 0x2` in the measure layout `0x6c81a0`'s `GxuFontCreateString` —
// vertical justify 2 = BOTTOM through `ComputeAnchor 0x5cdf70`'s `anchor.y = h +
// anchor.y` arm; horizontal 0 = left, the caller pre-subtracts hw → net h-centered), so
// the ink rises above the seat point while the claimed rect brackets it — the ref's
@@ -411,7 +405,7 @@ pub(crate) fn float_combat_text(
// The claimed box is the ref's MEASURED BLOCK under its own units quirk — the box
// the solver sees runs 1/G48 (~1.667×) taller and 1/G44 (~1.25×) wider than the
// rendered glyphs, the reference's generous size-proportional padding
// ([`claimed_box_px`], wow-re `worldtext-measured-block-wh-law.md`).
// ([`claimed_box_px`], `0x6c81a0`).
let claim = claimed_box_px(b.width(), size_value, viewport);
let (hw, hh) = (claim.x * 0.5, claim.y * 0.5);
let cx = screen
@@ -663,8 +657,8 @@ mod tests {
}
/// **A travelling spell's outcome word is DEFERRED, and it is GOLD** (decision 2229) — the
/// arrival half of the miss text, and the case the whole round came from: a resisted Fireball
/// reads "Resist" in spell gold when the ball lands, not white at GO.
/// arrival half of the miss text: a resisted Fireball reads "Resist" in spell gold when the
/// ball lands, not white at GO.
///
/// Four legs, each an independent branch of the law `0x607140` runs for every one of its
/// callers:
+1 -1
View File
@@ -1,6 +1,6 @@
//! The in-game **console command registry** (decision 2303) — the reference's `ConsoleCommand`
//! table, host side: `ConsoleCommandRegister 0x63f9e0` over a `TSExplicitList<CONSOLECOMMAND>`,
//! with parse / register / lookup / execute (wow-re `console/console.md`, the upper band). A
//! with parse / register / lookup / execute (the registry runtime, `[0x63f880, 0x640c50)`). A
//! subsystem registers its commands from its own plugin ([`ConsoleCommandApp::console_command`]),
//! the way each of the reference's registers into the one table, and `/console <line>` reaches
//! [`execute`] through the chat drain.
+4 -32
View File
@@ -46,38 +46,10 @@ pub(crate) fn install(build: BuildId) {
}));
}
/// `WOW_CRASH_INJECT=<at_secs>` — one deliberate main-thread panic, mid-run: the standing test
/// affordance for this module, in the shape of `perf::stall`'s injectors and armed beside them.
/// The end-to-end falsifier is a run with it set: the process must die with a
/// `crash-<unix>.txt` whose log tail ends in the `crash-inject` line below.
pub(crate) fn arm_injector(app: &mut bevy::prelude::App) {
use bevy::prelude::*;
if let Some(at) = std::env::var("WOW_CRASH_INJECT")
.ok()
.and_then(|v| v.parse::<f32>().ok())
{
app.insert_resource(CrashInject { at });
app.add_systems(Update, crash_inject);
}
}
#[derive(bevy::prelude::Resource)]
struct CrashInject {
at: f32,
}
fn crash_inject(
inject: bevy::prelude::Res<CrashInject>,
time: bevy::prelude::Res<bevy::prelude::Time<bevy::time::Real>>,
) {
if time.elapsed_secs() >= inject.at {
bevy::log::warn!(
"crash-inject: panicking the main thread at {:.1} s",
inject.at
);
panic!("crash-inject: deliberate panic (WOW_CRASH_INJECT)");
}
}
// The standing test affordance — `WOW_CRASH_INJECT=<at_secs>`, one deliberate main-thread panic
// mid-run — is `perf::crash_inject`, a dev root: this module ships to players and knows nothing
// of the `dev` seam (run_mode's one-door rule, 1176), and an instrument's home is with the
// instruments. Its module doc carries the cross-platform incident that moved it there.
/// Re-entrancy latch: a panic *inside* the hook (a poisoned lock, a failed write) must not
/// recurse into it. Never cleared on purpose — after one report the process is on its way out,
+74 -78
View File
@@ -8,8 +8,8 @@
//!
//! Regimes (decisions 0049 → 0083/0087):
//! - **Gait** — the directly-cross-faded ground/swim gaits + idle: Stand / Walk / Run / Fast-run by speed,
//! WalkBackwards when moving back, the swim ids when swimming (verified client selector, wow-5875-re
//! RF-0057). A change cross-fades to the new clip over its `blend_time` instead of snapping.
//! WalkBackwards when moving back, the swim ids when swimming (the client's selector `0x5fd8b0`).
//! A change cross-fades to the new clip over its `blend_time` instead of snapping.
//! - **Special** — the stand-state poses (sit/sleep/kneel: down → loop → up) as one-shot-bracketed
//! loops, **preemptible** (decision 0055), plus the jump's enter/hang. The jump's *landing* is NOT a
//! bracket: [`select::Mode::Land`] is a plain pick from the input at touchdown, freely overwritten the
@@ -20,7 +20,7 @@
//! torso-masked overlay ([`AnimClip::upper_node`]) while the base keeps driving the legs underneath —
//! never dropped, never bracket-gated.
//!
//! The playback **rate** of a locomotion clip is `speed / sequence.moveSpeed` (wow-5875-re `0x5fe2f0`), so
//! The playback **rate** of a locomotion clip is `speed / sequence.moveSpeed` (`0x5fe2f0`), so
//! a unit moving faster than the clip's design speed cycles its legs proportionally faster (the fix for a
//! backpedal that otherwise drags). Non-locomotion clips (idle, the jump/sit transitions — `moveSpeed 0`)
//! play at 1×. Death (decoded health 0) overrides everything.
@@ -42,9 +42,9 @@ use bevy::prelude::*;
use benilla_assets::AssetSet;
use benilla_world::schedule::WorldStage;
/// The pure animation-selection logic (RF-0057/0073 tables, movement/Special state, gait/swing/ready
/// picks, playback-rate math) — kept in its own file as it carries the bulk of the unit-tested selector
/// logic, separate from the Bevy driver systems in [`driver`].
/// The pure animation-selection logic (the `0x5fd100`/0073 tables, movement/Special state,
/// gait/swing/ready picks, playback-rate math) — kept in its own file as it carries the bulk of the
/// unit-tested selector logic, separate from the Bevy driver systems in [`driver`].
pub(crate) mod select;
pub(crate) use select::{
ease_strafe_yaw, move_flags, strafe_body_offset, swim_body_rotation, MovementState,
@@ -59,8 +59,8 @@ pub(crate) use twist::{wrap_pi, BodyTwist};
/// The unit animation-LOD gate (decision 0448): park an off-frustum rig's per-bone pose
/// evaluation — the clocks, the driver state machine, and the event tracks keep running, so
/// off-screen combat stays audible (0075) and a re-appearing unit snaps to the absolute-clock
/// pose. Shipped as a modernization; wow-re's 2026-08-13 election correction
/// (`outdoor-object-pass-election.md`) made it the faithful direction instead — decision 1473
/// pose. Shipped as a modernization, it is the faithful direction instead: the reference does not
/// tick an off-frustum unit (`0x683dd0`'s second walk) — decision 1473
/// records that, and what still diverges (parked rigs keep drawing; the reference keeps only
/// `MORE_AUDIBLE` creatures audible off-screen).
mod lod;
@@ -86,8 +86,8 @@ pub(crate) struct Wielded {
/// ([`select::ranged_load_anim`], the client's `0x5fd530` LUT; decision 0099 phase 5).
pub(crate) ranged: Option<(u8, u8)>,
/// The mainhand/offhand items' sheath *types* (1 back-2H · 2 back-staff · 3 hip · 4 shield ·
/// 0 none) — each picks its arm's draw/stow one-shot independently (VERIFIED wow-re
/// `sheath-anim-pick.md`: `(1 << (rec[+4] & 0x1f)) & 0x88` → HipSheath 90 for types {3, 7},
/// 0 none) — each picks its arm's draw/stow one-shot independently (`0x611930`:
/// `(1 << (rec[+4] & 0x1f)) & 0x88` → HipSheath 90 for types {3, 7},
/// Sheath 89 otherwise; the slot's own byte is the *only* input).
pub(crate) main_sheath: u8,
pub(crate) off_sheath: u8,
@@ -129,8 +129,7 @@ pub(crate) const UNIT_FLAG_DISARMED: u32 = 0x0020_0000;
pub(crate) const ITEM_CLASS_WEAPON: u8 = 2;
/// **The disarm ladder** — which single held slot `UNIT_FLAG_DISARMED` hides, given what each
/// melee hand holds (decision 1863; wow-re `disarm-weapon-gate-law.md` §2, byte-verified by a
/// trio including a cold decoder-first read, both class bodies identical).
/// melee hand holds (decision 1863; both class bodies identical).
///
/// The reference does not "empty a disarmed unit's hands". `GetWeapon(slot, visFlag = 0)` runs a
/// **two-probe ladder with main-hand precedence**, and the off-hand probe's polarity is the
@@ -198,46 +197,44 @@ impl Wielded {
/// **It carries that GUID**, because the reference's `[+0xc48]` is the target and not a flag, and
/// a second reader wants the unit and not just the fact: `0x6e3480`'s melee arm resolves
/// `[caster+0xc48]` and uses THAT unit's combat reach, which is what the spell tooltip's range
/// cell prints while you are auto-attacking (wow-re
/// `tooltip-damage-matrix-and-container-slots.md` §D4.2b). Every animation reader still asks only
/// cell prints while you are auto-attacking. Every animation reader still asks only
/// `With`/`Has`, which is unchanged by the payload.
#[derive(Component)]
pub(crate) struct Engaged(pub(crate) u64);
/// The local player has fired an auto-repeat spell (Auto Shot / wand Shoot) — the client's
/// `[+0xd58] & 0x200`, whose **only writer binary-wide** is the local cast-send tail (`0x6e593b`,
/// gated `AttributesEx2 & 0x20`; byte-verified, wow-re `ranged-shot-anim.md`). While it and the
/// gated `AttributesEx2 & 0x20`). While it and the
/// ranged **sheath** gate (`CUR == 2`) both hold, a standing unit's base idle is the ranged
/// weapon's Load clip ([`select::ranged_load_anim`]) — the client's resolver `0x5fd460`,
/// which gates on exactly this bit; local-player-only by construction (remote units never run
/// the local cast-send). A REMOTE shooter has no drawn idle at all: it plays LoadBow **once** at
/// the volley's single `SMSG_SPELL_START` through the PrecastKit ([`CastHold`]), then the fire
/// clip per GO over its ordinary idle (wow-re `shooter-stop-law.md` §J6 claim 2). Cleared
/// the volley's single `SMSG_SPELL_START` through the PrecastKit ([`CastHold`], `0x6e7901`), then
/// the fire clip per GO over its ordinary idle. Cleared
/// only by [`cancel_auto_repeat_local`]'s callers — the client's cancel `0x6ea080` (its `0x200`
/// clear `0x6ea113`), reached from the cast-result fail of the cached spell, the button
/// re-press toggle, melee attack-start, target death, the wand-only new-cast handoff, and
/// `SMSG_CANCEL_AUTO_REPEAT` — which vmangos DOES send (corrected 2026-08-05; see
/// [`crate::net::apply::spells::cancel_auto_repeat`]), so against a live server that packet, not
/// a local death watcher, is what ends a volley whose target dies (wow-re
/// `nocked-ammo-cancel.md`).
/// `crate::spell::net`'s `cancel_auto_repeat`), so against a live server that packet, not
/// a local death watcher, is what ends a volley whose target dies.
#[derive(Component)]
pub(crate) struct AutoRepeatArmed;
/// The weapon-visual hold — the client's `[+0xd58] & 0x400`, set for **ANY caster** (self,
/// remote players, NPCs) whose **ranged** spell visual plays (`0x60d020`, sole caller `0x6ec2cf`
/// inside `PlaySpellVisual`; byte-verified, wow-re `ranged-sheath-exempt-autorepeat.md` §Q4).
/// inside `PlaySpellVisual`).
/// Cleared like the client's id-matched `0x60d040` + its unconditional sibling `0x60fc50`: a
/// **non-ranged** visual play by the same caster (the stale-visual cleanup `0x6ec39e`), the
/// local cancel (`0x6ea12b`), melee attack-start, and the spline-move apply (`0x6020e8`, wow-re
/// `shooter-stop-law.md` §J1.3). Deliberately NOT cleared on sheath change or volley end — the
/// local cancel (`0x6ea12b`), melee attack-start, and the spline-move apply (`0x6020e8`).
/// Deliberately NOT cleared on sheath change or volley end — the
/// client's bit persists latent.
///
/// **It gates nothing here — but no longer for the reason this doc used to give.** `0x400` is
/// tested in exactly one place image-wide: `0x5fc3f0`'s Hold self-loop gates (`test ah,0x6` for
/// HoldBow 109, `test ah,0x4` for 110/111/112). 0994 recorded that the dispatcher is never
/// reached for a bow id (`shooter-stop-law.md` §J4.1) and concluded the bit is dead. **wow-re's
/// §5 refuted that absence proof** (decision 1544): the dispatcher has a second, deferred fire
/// site, so those gates DO execute — they are the Hold's own per-completion re-arm.
/// reached for a bow id and concluded the bit is dead. **That absence proof is wrong**
/// (decision 1544): the dispatcher has a second, deferred fire site (`0x7075af`), so those gates
/// DO execute — they are the Hold's own per-completion re-arm.
///
/// We still gate nothing on it, and that is now a *modelling* choice with a stated equivalence
/// rather than a claim about the binary. Our Hold is armed once and loops until a real recompute
@@ -251,10 +248,9 @@ pub(crate) struct AutoRepeatArmed;
pub(crate) struct RangedHold;
/// A unit's displayed **nocked ammo** — the client's `[+0xd28]`/`[+0xd2c]` ammo-model pair,
/// written only by `CGUnit::UpdateAmmoDisplay 0x60ba30` (byte-verified, wow-re
/// `nocked-ammo-cancel.md`): the ammo's `ItemDisplayInfo` id, rendered as a model on the unit's
/// **body** at HandArrow (35) — the ONE bone attach, bow-only (§E2; the old Special2/Special3
/// reading is refuted — those are model-directory selectors, §E1). Set per shot from the
/// written only by `CGUnit::UpdateAmmoDisplay 0x60ba30`: the ammo's `ItemDisplayInfo` id, rendered
/// as a model on the unit's **body** at HandArrow (35) — the ONE bone attach, bow-only (`0x60bb19`;
/// `0x479f40`'s 0x18/0x19 pick a model directory, not Special2/Special3). Set per shot from the
/// `SMSG_SPELL_START` `CAST_FLAG_AMMO` tail (any caster — the only source for a remote/NPC
/// shooter). This is the display-id CACHE; whether the arrow is visibly in the hand is the
/// [`NockLatch`] (the `$BWP`/`$BWR` cycle). Removed by the un-nock `0x60f530` paths: the local
@@ -265,7 +261,7 @@ pub(crate) struct NockedAmmo {
pub(crate) display_id: u32,
}
/// The client's nock latch `[+0xd58] & 0x4000` (wow-re `nocked-ammo-cancel.md` §G1/G4): SET at
/// The client's nock latch `[+0xd58] & 0x4000` (set `0x624b70`, cleared `0x60016c`): SET at
/// the body clip's **`$BWP`** BowPull keyframe (LoadBow 105 carries it at ~0.6 s — the visible
/// pull-from-quiver moment, which also (re)attaches the in-hand arrow), CLEARED at **`$BWR`**
/// BowRelease (AttackBow 46 carries it at ~0.067 s — the arrow leaves the hand as the missile
@@ -301,8 +297,8 @@ pub(crate) fn drive_nock_latch(
}
}
/// The one local auto-repeat cancel — benilla's `0x6ea080` (byte-verified whole, wow-re
/// `nocked-ammo-cancel.md`): clear the live key (`[0xceac30]`), drop the Load/Hold idle gates
/// The one local auto-repeat cancel — benilla's `0x6ea080`: clear the live key (`[0xceac30]`),
/// drop the Load/Hold idle gates
/// (the `0x200`/`0x400` bits — [`AutoRepeatArmed`] + [`RangedHold`]), un-nock the ammo
/// (`0x60f530` — [`NockedAmmo`]), and ack the server (`CMSG_CANCEL_AUTO_REPEAT_SPELL`, the
/// client's sole send site `0x6ea0c6`) — **sheath untouched**: the weapon stays drawn, the idle
@@ -310,7 +306,7 @@ pub(crate) fn drive_nock_latch(
/// live callers.
pub(crate) fn cancel_auto_repeat_local(
entity: Option<Entity>,
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
auto_repeat: &mut crate::spell::AutoRepeatActive,
commands: &mut Commands,
net: &crate::net::NetCommands,
) {
@@ -327,15 +323,14 @@ pub(crate) fn cancel_auto_repeat_local(
let _ = net.0.send(crate::net::ClientCommand::CancelAutoRepeat);
}
/// The one local **StopAttack** — benilla's `0x5ecac0` (wow-re §5 trio-verified,
/// `object-layer/scratch/melee-autorepeat-exclusion.md`): player-only, a no-op unless an attack
/// The one local **StopAttack** — benilla's `0x5ecac0`: player-only, a no-op unless an attack
/// lock is held, then `CMSG_ATTACKSTOP` (its builder `0x624370` pushes opcode `0x142`) followed by
/// the tail jump `0x5ecb63 → CancelQueuedCast 0x6e6f30` — which takes the **queued on-next-swing
/// spell** down with the attack: `0x6e6f30`'s first leg tests the inflight id `[0xceca88]` for
/// `Attributes & 0x404` and hands it to `CancelCast 0x6e4940(dl=1, reason 0x1c)`, whose casting arm
/// sends `CMSG_CANCEL_CAST 0x12f` naming it and then pops the slot through `0x6e4ad0`.
///
/// This is the un-queue [`crate::ui_cast::QueuedMeleeSpell`] names as its real clear path, and it
/// This is the un-queue [`crate::spell::QueuedMeleeSpell`] names as its real clear path, and it
/// is why a Raptor Strike ring goes dark the moment Auto Shot starts (the auto-repeat arm inside
/// the cast commit calls straight into here, `0x6e5976`, guarded by nothing but "the caster is the
/// active player").
@@ -346,12 +341,12 @@ pub(crate) fn cancel_auto_repeat_local(
/// are deliberately unmodelled, neither with a benilla reader: `[+0xc50]` ("locally initiated, not
/// server-confirmed") and `[+0xc54]` ("stop sent, awaiting the echo") — the latter's sole reader
/// image-wide is `0x5eccda`, inside `Attack 0x5ecb70`, which no benilla cast path enters while
/// engaged (see [`crate::ui_action::cast_send`]'s tail). The two ratio legs `0x5ecac0` runs before
/// engaged (see [`crate::spell::cast_send`]'s tail). The two ratio legs `0x5ecac0` runs before
/// the stop are `TriggerTutorial(0xa)` / `(0xb)` — the low-health / low-mana popups, not audio and
/// not part of this seam.
pub(crate) fn stop_attack_local(
engaged: bool,
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
queued_melee: &mut crate::spell::QueuedMeleeSpell,
net: &crate::net::NetCommands,
) {
if !engaged {
@@ -369,8 +364,8 @@ pub(crate) fn stop_attack_local(
}
}
/// The one local **StartAttack** — benilla's `0x5ecb70`, [`stop_attack_local`]'s twin (wow-re §5
/// `melee-autorepeat-exclusion.md` §5c–§5f). Two halves, and keeping them apart is the whole point:
/// The one local **StartAttack** — benilla's `0x5ecb70`, [`stop_attack_local`]'s twin. Two halves,
/// and keeping them apart is the whole point:
///
/// - **the send** (`0x5eccfd`) is gated by the already-attacking test at `0x5eccda` — skipped when
/// the attack lock is held, *unless* a stop is in flight (`[+0xc54]`), which is why
@@ -395,7 +390,7 @@ pub(crate) fn start_attack_local(
target: u64,
engaged: bool,
stop_in_flight: bool,
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
auto_repeat: &mut crate::spell::AutoRepeatActive,
sheath: &mut MessageWriter<SheathRequest>,
commands: &mut Commands,
net: &crate::net::NetCommands,
@@ -433,7 +428,7 @@ pub(crate) fn start_attack_local(
/// pseudo-spell runs (`TryCast`'s effect-`0x4e` short-circuit at `0x6e4c7a` dispatches straight to
/// it, ahead of every cast gate). It forks on the attack lock and nothing else: already attacking →
/// `0x6131d9 call 0x5ecac0` [`stop_attack_local`], otherwise `0x6131ee call 0x5ecb70`
/// [`start_attack_local`] (wow-re `melee-autorepeat-exclusion.md` §5f).
/// [`start_attack_local`].
///
/// The fork is the whole content, and it is worth a name because both halves used to be wrong here:
/// the button never toggled melee OFF, and it cancelled a running auto-repeat on *every* press —
@@ -447,8 +442,8 @@ pub(crate) fn toggle_attack_local(
entity: Entity,
target: u64,
engaged: bool,
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
queued_melee: &mut crate::spell::QueuedMeleeSpell,
auto_repeat: &mut crate::spell::AutoRepeatActive,
sheath: &mut MessageWriter<SheathRequest>,
commands: &mut Commands,
net: &crate::net::NetCommands,
@@ -476,13 +471,13 @@ pub(crate) fn toggle_attack_local(
/// The targeting side needs exactly this: `target::scan`'s `commit` runs the reference's
/// stop → select → re-swing law (`SetSelection 0x493540`'s own `0x493a08 call 0x5ecac0` and
/// `0x4938c8 call 0x5ecb70`), so every selection writer that can fire it has to carry both seams'
/// inputs. [`crate::ui_action::cast_send::CastLadder`] already carries them field by field for the
/// inputs. [`crate::spell::cast_send::CastLadder`] already carries them field by field for the
/// cast path and calls the free functions directly.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct AttackSeam<'w, 's> {
pub(crate) net: Res<'w, crate::net::NetCommands>,
pub(crate) queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
pub(crate) auto_repeat: ResMut<'w, crate::ui_action::AutoRepeatActive>,
pub(crate) queued_melee: ResMut<'w, crate::spell::QueuedMeleeSpell>,
pub(crate) auto_repeat: ResMut<'w, crate::spell::AutoRepeatActive>,
pub(crate) sheath: MessageWriter<'w, SheathRequest>,
pub(crate) ecs: Commands<'w, 's>,
/// Our own entity — the sheath snap's and the auto-repeat cancel's subject.
@@ -530,7 +525,7 @@ pub(crate) struct Casting {
/// The client's PlayAnimation **call order**, reconstructed. The real client has no pecking
/// order between a swing and a spell kit's anim landing in the same frame — its handlers run
/// synchronously in packet order and the LATER `PlayAnimation` call simply overwrites bone 0
/// (wow-re, the Eviscerate-timing round's arm C). benilla's swing and cast pipelines are
/// (`0x5fe2f0`). benilla's swing and cast pipelines are
/// separate message streams, so that order must ride the messages: every emitter of an
/// animation-bearing request ([`SwingMessage`], [`CastEvent`], [`EmoteAnim`], [`KitPush`])
/// stamps `next()` at emission — the wire drain stamps in packet order; scene-time emitters
@@ -581,13 +576,13 @@ pub(crate) enum CastEventKind {
Impact { weapon_visual: Option<u32> },
/// A projectile arrived at a **ground point** instead of on a unit — the client's per-tick
/// missile dispatch taking `0x61e1d0` → `0x61d870`, which is the **no-live-target** arm, not
/// a ground arm as such (a target that despawned mid-flight lands there too; wow-re
/// `missile-arrival-dispatch.md`). Ours is reached only by the single
/// a ground arm as such (a target that despawned mid-flight lands there too). Ours is reached
/// only by the single
/// missile a dest-targeted GO with an empty hit list launches
/// ([`spell_visual::MissileSpawn::ground_aim`]). `entity` is the **caster** — `0x61d870`
/// plays the kit on it, not on anything at the point — and `pos` is the arrival position,
/// the client's `extra` override (`0x61d8e7`: `lea edx,[esi+0x20]`). Plays `SpellVisual`
/// **field 13 at stage 3** (wow-re `spell-visual-lifecycle.md` §Q4).
/// **field 13 at stage 3**.
GroundImpact { pos: Vec3 },
}
@@ -605,8 +600,7 @@ pub(crate) struct CastHold {
pub(crate) spell_id: u32,
/// A ranged-slot shot's wind-up (`Attributes & 0x2` — Throw's ReadyThrown, the bow's
/// LoadBow): the client brackets every ranged kit play with ranged snaps (`0x60f34c`
/// before, the outer `0x6e5930`/`0x6e78f3` after — wow-re
/// `ranged-sheath-exempt-autorepeat.md`), so while this hold is live the sheath
/// before, the outer `0x6e5930`/`0x6e78f3` after), so while this hold is live the sheath
/// reconcile's force-stow never survives the frame ([`driver`]'s bracket override).
pub(crate) ranged: bool,
}
@@ -614,7 +608,7 @@ pub(crate) struct CastHold {
/// Per-hand weapon-grip state: whether a weapon is held in the right/left hand, so that hand's fingers
/// curl into a grip. The real client arms `HandsClosed` (AnimationData 15) on a hand's finger key-bones
/// **purely by that hand's attach point being occupied by a weapon** — not combat, not sheath state; a
/// forearm-mounted shield or an empty hand stays open (wow-re `hand-grip-mechanism.md`, `0x60b590` /
/// forearm-mounted shield or an empty hand stays open (`0x60b590` /
/// paperdoll `0x5059a0`). Written by the held-item resolver ([`crate::entities`], decision 0072) from the
/// weapon's hand attach point; read by [`driver::drive_hand_grip`] to hold/release the finger overlay.
#[derive(Component, Default, Clone, Copy, PartialEq, Eq)]
@@ -681,7 +675,7 @@ pub(crate) struct EmoteAnim {
}
/// A **state kit's animation id, which is a comparison and never a play** — `PlaySpellVisualKit`'s
/// stage-2 leg (VERIFIED, wow-re `state-kit-anim-and-stun-pose.md` §1/§2; decision 2085).
/// stage-2 leg (decision 2085).
///
/// `0x60edf0`'s tail has exactly one site that hands a kit's `+0x8` to the play primitive
/// (`0x60f3c5 call 0x5fe2f0`), and **stage 2 is diverted around it**: `0x60f387 jne` takes the
@@ -695,8 +689,8 @@ pub(crate) struct EmoteAnim {
/// It is therefore an animation-**cutting** mechanism, and the cut is not incidental: Charge
/// (22911) plays `Knockdown`(121) from its impact kit and then cuts it with its own state kit's
/// `Stun`(14), because 121 ≠ 14 forces the recompute. That is also why no stun ever shows the
/// `Stun` pose despite 23 kits naming it — the selector `0x5fd8b0` cannot produce 14 (its literal
/// outputs are enumerated in the note), so the recompute lands on `Stand`.
/// `Stun` pose despite 23 kits naming it — the selector `0x5fd8b0` cannot produce 14 (14 is not
/// among its literal outputs), so the recompute lands on `Stand`.
///
/// The driver holds the comparison because the armed id lives there. An aura **refresh** in place
/// emits nothing: the watcher `0x604d00` fires neither arm when a slot is rewritten with the same
@@ -709,7 +703,7 @@ pub(crate) struct BaseAnimRecompute {
}
/// Rear up a rider's mount (decision 0441 P2): resolved to a [`EmoteAnim`] one-shot of
/// MountSpecial(94) on the unit's MOUNT CHILD entity by [`flourish_to_anim`] — the §5-verified
/// MountSpecial(94) on the unit's MOUNT CHILD entity by [`flourish_to_anim`] — `0x608d50`'s
/// routing (the flourish plays on the mount model; the rider holds Mount(91) throughout).
/// Written by the net drain (`SMSG_MOUNTSPECIAL_ANIM` — observed riders only; our own echo
/// is dropped there) and by the self space-bar gate (`crate::player`, locally at send time).
@@ -776,15 +770,15 @@ pub(crate) struct SpellGoTargets {
pub(crate) hits: Vec<Entity>,
/// Units it missed, with the wire's `SpellMissInfo` code — a projectile still flies at
/// each, and its **arrival** plays the victim's defense clip for DODGE(3)/BLOCK(5) (the
/// client's `Missile_C::Update 0x61ceb0` dispatch — Dodge 30 / ShieldBlock 24, never Parry;
/// wow-re `smsg-attackerstate-consequences.md` §Q4). No impact kit plays. The deflect
/// client's `Missile_C::Update 0x61ceb0` dispatch — Dodge 30 / ShieldBlock 24, never Parry).
/// No impact kit plays. The deflect
/// *flight* visual (the projectile glancing off) stays a named approximation — ours ends
/// the flight at the target.
pub(crate) misses: Vec<(Entity, u8)>,
/// The ground point a dest-targeted cast launched at (`TARGET_FLAG_DEST_LOCATION`), in
/// **bevy** coords (converted at the apply seam like every scene position). A ground AOE's
/// GO arrives with empty hit/miss lists and only this. No consumer reads it yet — the
/// dest-anchored launch visual is pending the dispatched wow-re read (the persistent area
/// dest-anchored launch visual is still an open question (the persistent area
/// effect anchors to the DynamicObject create, not to this packet).
pub(crate) dest: Option<Vec3>,
/// The GO's ammo block (`castFlags & 0x20`, a ranged shot): the `ItemDisplayInfo` id of the
@@ -847,6 +841,7 @@ pub(crate) use impact::{DefenseAnim, PendingImpacts, SwingFlush, SwingImpact, Sw
/// one-shots ([`EmoteAnim`]) and kit sounds ([`spell_visual::SpellKitSound`]).
mod blood;
mod env_damage;
pub(crate) mod net;
pub(crate) mod spell_visual;
use blood::{blood_spurts, load_blood_tables};
use env_damage::{hard_landing_dust, load_env_damage_table};
@@ -912,8 +907,8 @@ pub(crate) struct AnimDriver {
/// (`0x5fe48e`, the fields' only reader), and `0x5fe2f0` is the **single** animation entry
/// point in the image — 40 call sites, locomotion among them (`0x602c60` → `0x5fd9e0` →
/// `0x5fd8b0` → `0x5fd100`), which is why this is not a one-shot flag: a unit that simply
/// starts running consumes the arm, and that is what starts Charge's trail (wow-re
/// `charproc8-trail-draw-state.md` §11.5/§11.6). benilla's driver is one batched system, not
/// starts running consumes the arm, and that is what starts Charge's trail.
/// benilla's driver is one batched system, not
/// a per-play function, so the arm cannot be read at the call the way the reference reads it.
started_anim: bool,
/// A **masked upper-body one-shot** in flight (decision 0087): a swing/emote the live-state route
@@ -952,15 +947,15 @@ pub(crate) struct AnimDriver {
/// the landing of a bracket-less step-off fall (which must still run the `0x602c60` land pick
/// even though no Special drove the arc).
was_falling: bool,
/// The **deferred combat one-shot** — the client's `CGUnit+0xd60` cache (wow-re
/// `combat-anim-fastpath.md`, decision 0406): a combat clip requested while another combat
/// The **deferred combat one-shot** — the client's `CGUnit+0xd60` cache (decision 0406):
/// a combat clip requested while another combat
/// clip plays is NOT armed — the playing clip's rate doubles (op6 2.0f) and the request
/// parks here, played by the driver the moment no one-shot is live (the client's
/// base-recompute read). Any normal arm clears it (the client's `0x5fe48e` writes −1 on
/// every non-fast-path PlayAnimation; the driver clears at its play sites).
deferred: Option<u16>,
/// The armed looping arm's **replay window** (decision 0516 — wow-re `loop-replay-fidget`
/// §7/§7d): the variation node the loop armed + its rolled budget `R` (the client's
/// The armed looping arm's **replay window** (decision 0516): the variation node the loop
/// armed + its rolled budget `R` (the client's
/// `block+0xbc`; the window is `R` clip-lengths wide, op4 `0x7126d8` — live for LOOPS too,
/// correcting 0117's "loops ignore it"). The per-frame watchdog (`0x719370`'s transcription
/// in `drive_animations`) re-arms the id when the node — still the MAIN armed animation —
@@ -1043,9 +1038,9 @@ struct Overlay {
looping: bool,
}
/// A key-bone cross-fade in flight ([`AnimDriver::overlay_fade`], decision 0878 — wow-re
/// `oneshot-lifecycle.md` §5.4). `out` is the **retiring** node, holding the outgoing pose the
/// client snapshots into the bone's secondary slot (`rep movsd +0x98 → +0xc4`); it is `None` when
/// A key-bone cross-fade in flight ([`AnimDriver::overlay_fade`], decision 0878). `out` is the
/// **retiring** node, holding the outgoing pose the client snapshots into the bone's secondary
/// slot (`0x7123af`: `rep movsd +0x98 → +0xc4`); it is `None` when
/// a fresh clip fades in over the *inherited base* pose, which needs no node of its own — the base
/// is already there. `left`/`total` are the window: the fixed 150 ms of a fade-to-rest, or the
/// incoming clip's own blendTime on a blended re-arm.
@@ -1104,7 +1099,7 @@ impl AnimDriver {
/// The `AnimationData.dbc` id of the clip this unit is currently playing (target gait, a Special's
/// enter/loop/exit one-shot, or Death — which the death override records as the gait). `None` before
/// the first selection. The mouse pick reads it for the **current animation's** bounds sphere — the
/// real client's broad-phase volume tracks the playing sequence (wow-re pick-volume RE).
/// real client's broad-phase volume tracks the playing sequence (`0x7089c0`).
pub(crate) fn active_anim(&self) -> Option<u16> {
match self.mode {
Mode::Gait => self.gait,
@@ -1165,6 +1160,7 @@ pub(crate) struct CreatureAnimPlugin;
impl Plugin for CreatureAnimPlugin {
fn build(&self, app: &mut App) {
net::register(app);
twist::plugin(app);
lod::plugin(app);
breath::register(app);
@@ -1229,7 +1225,7 @@ impl Plugin for CreatureAnimPlugin {
arm_mount_poof_fx,
// The aura-slot watcher: state kits persist for the aura's life (the bread).
arm_aura_state_fx,
// The pending-morph latch (wow-re `shapeshift-morph-cloud.md`): the aura
// The pending-morph latch (`0x5ff0c0` arms, `0x60abe0` replays): the aura
// add/remove edges arm it, and a display swap's rebuild replays the latched
// spell's impact kit — the shapeshift cloud, both directions. Arm before
// replay: the swap message crosses from last frame's teardown, and its
@@ -1314,7 +1310,7 @@ impl Plugin for CreatureAnimPlugin {
}
}
/// Resolve `id` to the id this model actually plays (decision 0082, wow-re `anim-id-resolution.md`):
/// Resolve `id` to the id this model actually plays (decision 0082, `0x711bf0`):
/// [`ModelAnimations::resolve`] via `catalog` when `AnimationData.dbc` has loaded, else identity — a
/// brief window at startup ([`load_anim_data`](sheath::load_anim_data) runs at `Startup`, so this
/// degrade is only ever live for the first few frames, same shape as `anim_data.map_or(0, ..)`
@@ -1356,13 +1352,13 @@ mod attack_stand_tests {
// A dead-letter net channel: the send is `let _ = …` and no packet is under test here.
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.insert_resource(crate::ui_action::AutoRepeatActive(None));
app.insert_resource(crate::spell::AutoRepeatActive(None));
let me = app.world_mut().spawn_empty().id();
app.add_systems(
Update,
move |mut commands: Commands,
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
mut auto_repeat: ResMut<crate::spell::AutoRepeatActive>,
mut sheath: MessageWriter<SheathRequest>,
net: Res<crate::net::NetCommands>| {
start_attack_local(
@@ -1397,7 +1393,7 @@ mod attack_stand_tests {
mod nock_latch_tests {
use super::*;
/// The `$BWP`/`$BWR` nock cycle (wow-re `nocked-ammo-cancel.md` §G1/G4): the pull keyframe
/// The `$BWP`/`$BWR` nock cycle (set `0x624b70`, cleared `0x60016c`): the pull keyframe
/// latches (only a unit with a cached ammo display), the release keyframe clears — the arrow
/// appears as she draws it and leaves the hand with the shot.
#[test]
@@ -1452,7 +1448,7 @@ mod nock_latch_tests {
mod disarm_tests {
use super::*;
/// **The ladder, hand by hand** (decision 1863; wow-re `disarm-weapon-gate-law.md` §2c). The
/// **The ladder, hand by hand** (decision 1863; `0x5ec240`/`0x605e30`). The
/// reference hides **exactly one weapon**, main hand first — so a disarmed dual-wielder
/// punches with the main hand and still swings its off-hand weapon. Every selector downstream
/// reaches its own unarmed leg by its own table; there is no disarm case in any of them.
@@ -1,5 +1,5 @@
//! The melee **blood spurt** (decision 0137 phase 3) — the fourth element of the victim-feedback
//! set (wow-re `melee-blood-spurt.md`, byte-verified `0x624530 → 0x625010`): a landed melee blow
//! set (`0x624530 → 0x625010`): a landed melee blow
//! hangs a small particle-emitter model (`Particles\BloodSpurts\*.mdx`) on the victim, front or
//! back by where the attacker stands, sized by the crushing bit, colored by the creature's blood
//! type. Independent of the wound flinch and the floating text; rides the 0122 kit-effect spawn
@@ -30,7 +30,7 @@ use super::{SpellKitFx, SwingImpact, SwingMessage};
/// `cvars::REGISTERED` takes one row per knob a settings page actually wires (0137 phase 3).
const VIOLENCE_LEVEL: usize = 2;
/// The victim-model M2 attachment ids the spurt hangs on (`melee-blood-spurt.md`: CEffect at
/// The victim-model M2 attachment ids the spurt hangs on (`0x625010`: CEffect at
/// attach tag 0xf front / 0x10 back — present on every character and creature model checked).
const ATTACH_FRONT: u16 = 15;
const ATTACH_BACK: u16 = 16;
@@ -1,6 +1,5 @@
//! **Cold breath** — the visible vapour a unit puffs from its mouth in a cold zone (B233,
//! decision 1149; wow-re `object-layer/scratch/cold-breath-law.md`, §5 two independent pairs +
//! byte arbitration).
//! decision 1149).
//!
//! The client hangs three mutually-exclusive puffs off one animation event, and they are the
//! `$BTH` family: cold vapour, underwater bubbles, and the drunk's bubbles. This module owns the
@@ -41,7 +40,7 @@
//! ## Stated divergences
//!
//! - **The underwater rung is not modelled** — but it is now fully specified rather than open:
//! what is missing is the work, not the law (`cold-breath-law.md` §4b). The test is
//! what is missing is the work, not the law (`0x607710`'s liquid arm). The test is
//!
//! ```text
//! underwater ⟺ 5.0 + SCALE_X · boxHeight < liquidSurfaceZ − unitZ
+48 -53
View File
@@ -59,7 +59,7 @@ use wound::{wound_evict, wound_trigger, wound_upkeep, WoundEdge};
/// value as the per-arm sheath ceremony's overlay weight.
const ONESHOT_OVERLAY_WEIGHT: f32 = 8.0;
/// The key-bone **fade-to-rest** window (decision 0878 — wow-re `oneshot-lifecycle.md` §5.4): a
/// The key-bone **fade-to-rest** window (decision 0878): a
/// finished upper-body one-shot is never stopped. The client's per-frame advance latches the
/// completion (`0x719370`), the deferred event reaches `CGUnit::OnAnimationFinished 0x5fc920` the
/// same frame, and that calls op4 with `param_3 = -1` (`0x5fcacb`) — which snapshots the clip's
@@ -83,7 +83,7 @@ enum OneShotReq {
}
/// The currently-armed one-shot the combat fast-path tests — the client's key-bone-else-bone-0
/// current-id read (`0x5fe422`, wow-re `combat-anim-fastpath.md` §1): the masked overlay while
/// current-id read (`0x5fe422`): the masked overlay while
/// its node still plays, else the full-body [`Mode::Swing`] clip while unfinished. Gait and
/// Special clips are never returned; their ids aren't combat, so nothing is lost at the
/// classifier gate.
@@ -194,8 +194,7 @@ fn overlay_fade_upkeep(drv: &mut AnimDriver, player: &mut AnimationPlayer, dt: f
}
}
/// The **TRANSPLANT** (`0x5fe919` — wow-re `oneshot-lifecycle.md` §3a, the half
/// `anim-composition-model.md` §2 was missing; decision 0878). A base **locomotion** clip
/// The **TRANSPLANT** (`0x5fe919`, decision 0878). A base **locomotion** clip
/// requested while bone 0 still plays a live **CAST** or **COMBAT** one-shot does not replace it:
/// the client copies the bone-0 descriptor — its id, its rate, and `+0x08` the clip's **live
/// elapsed position** — onto the key-bone with `blendFlag = 0`, then hands bone 0 the request. The
@@ -319,8 +318,8 @@ pub(super) fn drive_animations(
// …and the host's granted modes, for the same reason `unify` reads them on the rider.
Option<&crate::net::UnitMoveModes>,
// …and the rider's own `OBJECT_FIELD_SCALE_X`: a mount child's transform carries only its
// `CreatureDisplayInfo` column, and the mount renders at the PRODUCT of the two (the
// byte-verified mount composition, wow-re `0x613ef0`). The rate divisor wants that product
// `CreatureDisplayInfo` column, and the mount renders at the PRODUCT of the two
// (`0x613ef0`). The rate divisor wants that product
// — the mount model's world scale — not the child's local column (decision 0903).
&Transform,
// …and whether the rider is US, which only the debug trace reads (decision 0906).
@@ -564,7 +563,7 @@ pub(super) fn drive_animations(
if movement.is_none() {
mv.stand_state = store.map_or(0, |s| s.0.unit_stand_state());
}
// Mounted forces stand-state 0 on every recompute (`0x5fdf80`, wow-re sheath-policy §3):
// Mounted forces stand-state 0 on every recompute (`0x5fdf80`):
// no sit/sleep/kneel pose can hold in the saddle.
if mounted {
mv.stand_state = 0;
@@ -578,7 +577,7 @@ pub(super) fn drive_animations(
// mounts are mutually exclusive anyway.
mv.stealthed = store.is_some_and(|s| s.0.unit_is_stealthed());
let moving = mv.flags & move_flags::ANY_MOVE != 0;
// The airborne arc's bookkeeping (wow-re land-anim-height-gate + rf57b §2): on the arc's
// The airborne arc's bookkeeping (jump `0x60e480`, land `0x602c60`): on the arc's
// FIRST airborne frame, its launch vertical speed splits a **jump** (upward — the client's
// JumpStart/Jump bracket rides the MSG_MOVE_JUMP event) from a **step-off fall** (level or
// downward — no bracket; the gait freezes until FALLINGFAR latches Fall 40, and an
@@ -629,7 +628,7 @@ pub(super) fn drive_animations(
if falling && (!was_falling || launched) {
drv.jump_arc = mv.vertical_speed > JUMP_ARC_MIN_UP;
}
// The airborne-freeze's exact gate (`0x5fd8e8`, §5-arbitrated — decision 0868):
// The airborne-freeze's exact gate (`0x5fd8e8`, decision 0868):
// keep-current iff `FALLING && (FALLINGFAR || vz ≠ 0)`. The vz clause pins every real
// arc (ballistics make vz ≠ 0 from the first integrated substep); the one uncovered
// case is a fresh walk-off's vz == 0 substep, where the client genuinely may re-pick.
@@ -638,7 +637,7 @@ pub(super) fn drive_animations(
let airborne_frozen =
falling && (mv.flags & move_flags::FALLING_FAR != 0 || mv.vertical_speed != 0.0);
// The idle re-face turn-shuffle (decision 0123 — the client's facing-delta latch
// `0x607ed0`, wow-re `loop-replay-fidget.md` §5b): a stationary creature easing its yaw
// `0x607ed0`): a stationary creature easing its yaw
// toward its target reads as *turning* to the anim layer, so the gait picks
// ShuffleLeft/Right and each Shuffle→Stand return re-arms (and, relaxed, re-rolls) Stand
// — the fidget's recurring trigger. Gated like the client's can-fidget gate (`0x5fce30`:
@@ -709,8 +708,7 @@ pub(super) fn drive_animations(
continue;
}
// **The mount transition's own bone-0 arm** (wow-re `mount-cast-anim-disposition.md` §1 +
// `mount-composition.md` Q4, §5 2026-08-03). Both legs of the `UNIT_FIELD_MOUNTDISPLAYID`
// **The mount transition's own bone-0 arm**. Both legs of the `UNIT_FIELD_MOUNTDISPLAYID`
// watcher arm the BODY's bone 0 through op4 `0x7121a0` on the PRIMARY slot: the build
// `0x607b44` plays **91 `Mount`** with a cross-fade, the teardown `0x607ce0` plays seq
// **0 `Stand`** with the cross-fade **off** (decision 0931 — the two legs are the same
@@ -780,9 +778,8 @@ pub(super) fn drive_animations(
drv.deferred = None; // a normal arm clears the fast-path cache (`0x5fe48e`)
}
// The loot kneel's trigger (see [`select::LOOT`]; byte-verified, wow-re
// `loot-anim-leg.md` §5/§8 — decisions 0515 / 1471 / 1477): the leg needs **two**
// predicates, and both SPLIT on IsActivePlayer.
// The loot kneel's trigger (see [`select::LOOT`]; decisions 0515 / 1471 / 1477): the
// leg needs **two** predicates, and both SPLIT on IsActivePlayer.
//
// **Self.** Predicate A (`0x6126b0`) is "a loot session is open" — the client-local
// loot-target latch. Predicate B (`0x612710`) is the per-object-CLASS filter: which
@@ -818,9 +815,9 @@ pub(super) fn drive_animations(
} else {
current_special(&mv, drv.jump_arc)
};
// The loot leg outranks the standState pose resolver (the rf57 chain calls loot before
// standState): a looting unit kneels at the loot, never into a sit/sleep bracket. The
// airborne Specials keep their precedence (the chain's airborne freeze runs first).
// The loot leg outranks the standState pose resolver (the `0x5fd8b0` chain calls loot
// before standState): a looting unit kneels at the loot, never into a sit/sleep bracket.
// The airborne Specials keep their precedence (the chain's airborne freeze runs first).
let special = special.filter(|s| !(looting && matches!(s, select::Special::Pose(_))));
// ── Sheath state (decision 0080). The unit's rendered sheath is the **client-side
@@ -829,9 +826,9 @@ pub(super) fn drive_animations(
// *changes* (the `0x604c70` field-apply — a remote unit's own client volunteered the
// change; our own echo arrives already-committed and adopts as a no-op), and written by
// the one-setter requests here + the per-animation reconcile below. Every transition
// **snaps by default** — byte-verified across all 24 SetSheatheState call sites (wow-re
// `sheath-policy.md`): only the manual toggle's request carries the ceremony, and the
// draw/stow sound rides the ceremony playback (no clip → no sound).
// **snaps by default** — across all 24 call sites of SetSheatheState `0x611cf0`: only the
// manual toggle's request carries the ceremony, and the draw/stow sound rides the ceremony
// playback (no clip → no sound).
let sheath_frame_start = drv.sheath_cur;
let sheath_byte = store.and_then(|s| s.0.unit_sheath_state()).unwrap_or(0);
if drv.sheath_cur.is_none() || drv.sheath_byte != Some(sheath_byte) {
@@ -849,7 +846,7 @@ pub(super) fn drive_animations(
// stow point). A ceremony without playable clips degrades to the snap.
if let Some(req) = pending_sheath.get(&entity) {
let cur = drv.sheath_cur.unwrap_or(0);
// Mounted is a persistent DRAW-BLOCK (wow-re `sheath-policy.md` §3: the client's
// Mounted is a persistent DRAW-BLOCK (the client's
// recompute `0x5fdf80` tests the mount model FIRST and forces stowed on every
// PlayAnimation — nothing can stay drawn on a mount). The client's composite
// recomputes constantly (the mount's own gait plays); our rider track goes silent
@@ -917,8 +914,8 @@ pub(super) fn drive_animations(
// (op4 `blendFlag≠0` copies the outgoing pose over `+0xc4..`). So this frame's plays are
// tracked — full-body plays (bone 0: the base track) evict a full-body wound; masked-slot
// plays (the key-bone) evict a masked wound. A play on the *other* bone leaves the wound
// decaying (the §5's inherited-swing case). Mode/gait changes proxy the mode machine's own
// base plays; the flags catch the same-id re-plays the proxy can't see.
// decaying (the kernel `0x714260`'s inherited-swing case). Mode/gait changes proxy the
// mode machine's own base plays; the flags catch the same-id re-plays the proxy can't see.
// ── The **base-animation lock**'s clearer, keyed on the FINISHED id and run before
// anything re-picks the base this frame — the reference clears at `0x5fc9c6`, above
// `OnAnimationFinished`'s own reason branch, so completion and pre-emption both release it
@@ -947,7 +944,7 @@ pub(super) fn drive_animations(
return None;
}
// `DO_NOT_PLAY_WOUND_ANIM` takes the **parry** with the flinch (`0x60ec1f`, the bit's
// second and last consumer — wow-re `wound-parry-gate-and-injury-vocal.md` Q1/Q6).
// second and last consumer).
// The gate is inside `0x60ec00`, which the `$CPP` ladder enters only on
// victimState 3: DODGE/DEFLECT (30) and BLOCK (24) go straight to PlayAnimation from
// `0x624a90`/`0x624a74` and are NOT gated — a flagged creature still dodges and still
@@ -1012,7 +1009,7 @@ pub(super) fn drive_animations(
// hit re-parks its own request — both what the client's `0x5fe48e`/`0x5fe480` do).
// NEVER mid-air: the read (`0x5fd392`, inside the `0x5fd360` recompute arm) sits
// downstream of the airborne-freeze, so a park made mid-arc waits — and dies at the
// landing play's clear (§5-verified, decision 0868).
// landing play's clear (decision 0868).
if requests.is_empty()
&& drv.deferred.is_some()
&& !airborne_frozen
@@ -1028,7 +1025,7 @@ pub(super) fn drive_animations(
if drv.base_lock.refuses() {
continue;
}
// The COMBAT FAST-PATH (`0x5fe43c`–`0x5fe48b`, wow-re `combat-anim-fastpath.md`,
// The COMBAT FAST-PATH (`0x5fe43c`–`0x5fe48b`,
// decision 0406): a combat clip requested while another combat clip is playing is
// NOT armed — the CURRENT clip's rate doubles (op6 2.0f re-times its remainder,
// pose-continuous) and the request parks in the `+0xd60` cache to play afterwards.
@@ -1075,7 +1072,7 @@ pub(super) fn drive_animations(
}
continue;
}
// Mounted forces the masked route (byte-verified, wow-re mount-composition B1:
// Mounted forces the masked route (`0x5fe2f0`'s mounted branch:
// upper-body one-shots route to the key-bone while mounted, and the 91-force would
// reclaim bone 0 on the next play anyway): a full-body /wave would replace the seat
// pose with a standing wave floating over the saddle.
@@ -1097,7 +1094,7 @@ pub(super) fn drive_animations(
// swaps the torso in one frame. Note the full-body branch below deliberately does
// NOT touch the overlay: on that route the client's key-bone slot keeps its current
// descriptor (a dedup no-op), so a standing emote over a still-running masked swing
// plays legs-only underneath it (`0x5fe930`; wow-re claim 6 CONFIRMED).
// plays legs-only underneath it (`0x5fe930`).
retire_overlay(
&mut drv,
&mut player,
@@ -1119,7 +1116,7 @@ pub(super) fn drive_animations(
// Full-body route (standing idle / airborne non-combat), or the split-boneless
// masked fallback (the client's −1 key-bone sentinel arms bone 0 too): the clip
// replaces the base on bone 0 — **even over a Special**. The client never drops a
// play: one slot, last-writer-wins (decisions 0083/0087; the wow-re §3 route puts
// play: one slot, last-writer-wins (decisions 0083/0087; the route `0x5fe6c8` puts
// a jump-in-place cast/emote on bone 0, replacing the hang — decision 0864 is the
// ref's mid-air cast). Cutting an airborne clip freezes the outgoing node first —
// the pose-snapshot decay of the client's op4 blend, scoped exactly like
@@ -1186,7 +1183,7 @@ pub(super) fn drive_animations(
if special_edge {
drv.deferred = None;
}
// The looping-variation ADVANCE (decision 0516 — wow-re `loop-replay-fidget.md` §7/§7d,
// The looping-variation ADVANCE (decision 0516 —
// the per-frame watchdog `0x719370`): every looping arm installed a window `R`
// clip-lengths wide; when the armed node — still the MAIN animation (the client checks
// the armed block: any newer arm superseded the window) — completes its `R` passes, the
@@ -1341,7 +1338,7 @@ pub(super) fn drive_animations(
if done {
// Freeze the held frame, then retire it: the client's snapshot copies an already
// *expired* window, so the secondary clamps to `seq.end` and the fade runs from
// the clip's final pose (`oneshot-lifecycle.md` §5.4).
// the clip's final pose (`0x7123af`).
if let Some(a) = player.animation_mut(ov.node) {
a.set_speed(0.0);
}
@@ -1480,18 +1477,18 @@ pub(super) fn drive_animations(
// 1. `0x60ea9f` → `0x6125f0`: the victim's cached creature template carries
// `type_flags` bit `0x8`, **DO_NOT_PLAY_WOUND_ANIM** — a skeleton, a ghost, a
// bone golem has no flesh to recoil, and the reference refuses every flinch it
// would ever take, melee and spell alike (decision 2068; wow-re
// `melee-blood-spurt-suppression.md` §6). It gates the ANIMATION only: the blood
// spurt is a separate system reading [`SwingImpact`] itself, and wow-re §8 Q1 is
// explicit that nothing keyed on creature type suppresses it — a skeleton bleeds.
// would ever take, melee and spell alike (decision 2068). It gates the ANIMATION
// only: the blood spurt is a separate system reading [`SwingImpact`] itself, and
// nothing keyed on creature type suppresses it (`0x624530` → `0x625010`) — a
// skeleton bleeds.
// A record we have not received yet reads as NOT flagged, which is `0x6125f0`'s
// own null-record leg (`return record ? … : false`), so a creature whose query is
// still in flight flinches there too.
// 2. `0x60eaac`–`0x60eac8` (wow-re `charproc-rate-override-wound-gate.md`, decision
// 2063): a CharProc-11 rate-override node on the unit's effect list — the freeze
// auras' node, Freezing Trap / Ice Block / petrify / web wrap — refuses EVERY
// flinch for as long as it lives. The gate is the node's presence, not its rate
// (kit 3071's 1.0 gates too), so it reads the node list, not the pause it applies.
// 2. `0x60eaac`–`0x60eac8` (decision 2063): a CharProc-11 rate-override node on the
// unit's effect list — the freeze auras' node, Freezing Trap / Ice Block /
// petrify / web wrap — refuses EVERY flinch for as long as it lives. The gate is
// the node's presence, not its rate (kit 3071's 1.0 gates too), so it reads the
// node list, not the pause it applies.
//
// `no_wound_anim` is read once above, where the parry pick — the flag's other
// consumer — needs the same answer.
@@ -1535,15 +1532,15 @@ pub(super) fn drive_animations(
// (gossip, loot, mounts) inherit the policy with zero new sheath wiring.
// The reconcile tests the **requested** id, not the model-resolved substitute (decision
// 0125, director-eyes falsification of 0082's resolved-id reading): the client's arm
// descriptor carries the id that was ASKED FOR (the cast-override §2.1 writes the kit's
// own anim id even on a model lacking the sequence), and `0x5fdb50` reads that
// descriptor carries the id that was ASKED FOR (the cast-override `0x5fde80` writes the
// kit's own anim id even on a model lacking the sequence), and `0x5fdb50` reads that
// descriptor. The two differ only when a model lacks the requested clip — the ground
// truth is GnollCaster.m2: no ReadySpellDirected(51) at all, its hold falls back to a
// flags-less Stand for *playback*, yet the ref still stows the staff for the whole
// windup — only 51's own `&4` row can do that. (0082's chicken rationale collapses under
// the same lens: requested Attack2H's 0x20 vs resolved AttackUnarmed's 0x10 is
// unobservable on a weaponless chicken.) Byte arbitration of `0x5fdb50`'s miss path is
// dispatched to wow-re; the director's eyes outrank the old reading meanwhile.
// unobservable on a weaponless chicken.) The byte reading of `0x5fdb50`'s miss path is
// open; the director's eyes outrank the old reading meanwhile.
// The reconcile must see **every play** (decision 0087 (d)): the client's `0x5fdf80` runs
// inside `PlayAnimation` itself, so a *masked* one-shot — which never touched `mode`, so
// `active_anim()` still reports the base gait — is reconciled all the same. A masked emote
@@ -1577,9 +1574,8 @@ pub(super) fn drive_animations(
if let Some(forced) =
select::reconcile_sheath(cur, anim, flags, engaged, is_self, sheath_byte, mounted)
{
// The ranged wind-up **bracket** (wow-re `ranged-sheath-exempt-autorepeat.md`,
// Q1's ordering law): the client surrounds every ranged kit play with ranged
// snaps — `0x60f34c` before the play, the outer send/START `SetSheatheState
// The ranged wind-up **bracket**: the client surrounds every ranged kit play with
// ranged snaps — `0x60f34c` before the play, the outer send/START `SetSheatheState
// (2,1,1)` after — so ReadyThrown 108's genuine force-stow (NOT in the `0x5fe180`
// exempt set) exists but never renders: the snap always wins the frame. Our
// frame-spread ECS can't reproduce the call-stack order, so the bracket is
@@ -1607,7 +1603,7 @@ pub(super) fn drive_animations(
}
}
// Leaving the ranged sheath stance un-nocks (the client's `0x60fc72` un-nock, gated
// `[+0xd40] != 2`; wow-re `nocked-ammo-cancel.md`): however the state left 2 this frame
// `[+0xd40] != 2`): however the state left 2 this frame
// — a stow/melee request, the reconcile, the server byte — the ammo model drops.
// Re-drawing shows no ammo until the next `SMSG_SPELL_START` re-affirms it.
if sheath_frame_start == Some(2) && drv.sheath_cur != Some(2) {
@@ -1617,8 +1613,7 @@ pub(super) fn drive_animations(
}
// …and **TRANSLATING** un-nocks too — the arrow leaves the hand and the string relaxes,
// while the cached ammo display survives for the next pull and the weapon stays DRAWN.
// Byte-verified (wow-re `shooter-stop-law.md` §J1.1, which refuted our recorded "nothing
// in the client un-nocks on movement"): `0x60e480` is a movement-OPCODE dispatcher whose
// `0x60e480` is a movement-OPCODE dispatcher whose
// jump-table arm 0 runs `0x60e4ac call 0x60f530` (un-nock) + `RecomputeBaseAnim(-1)`, and
// arm 0 is reached by exactly MSG_MOVE_START_{FORWARD, BACKWARD, STRAFE_LEFT,
// STRAFE_RIGHT, SWIM} (181/182/184/185/202). **Turning and stopping do NOT un-nock** —
@@ -1628,8 +1623,8 @@ pub(super) fn drive_animations(
//
// The reference's trigger is the START opcode (an edge); a level test is equivalent here
// because the only thing that can re-latch is `$BWP`, authored solely in the Load clips —
// which a translating unit never plays. Sheath is untouched (§J1's claim-4 split: REFUTED
// for un-nock, CONFIRMED for stow — `[+0xd40]` has no movement-path writer).
// which a translating unit never plays. Sheath is untouched (`[+0xd40]` has no
// movement-path writer: only the ctor's `0x5fafa0` and `0x611cf0` write it).
if mv.flags & (move_flags::ANY_MOVE | move_flags::SWIMMING) != 0 && nock_latched {
commands.entity(entity).remove::<super::NockLatch>();
}
@@ -7,7 +7,7 @@ use bevy::prelude::*;
use super::super::{HandGrip, HAND_GRIP_WEIGHT};
/// Hold each hand's `HandsClosed` finger pose while that hand grips a weapon, release it when it doesn't
/// — the persistent weapon-grip overlay (wow-re `hand-grip-mechanism.md`: the client arms the finger
/// — the persistent weapon-grip overlay (`CloseHand 0x479660`: the client arms the finger
/// key-bones with `HandsClosed` purely by a weapon occupying that hand's attach point). Played *over* the
/// gait on the finger-mask nodes, so the arm keeps its animation while only the fingers curl; on release
/// the fingers fall back to the base gait's flat Stand rest. Idempotent — plays only on the open→closed
@@ -91,8 +91,8 @@ pub(super) fn run(
Mode::Entering(sp) => {
// The swim re-latch does NOT cut the hop's kick: JumpStart PLAYS OUT over the
// re-latch and the swim gait resumes only at its end (decision 0517 —
// director-corrected against the ref; the §5's static cut-at-relatch law could
// not reproduce the screen and is flagged wow-re-side for a live capture).
// director-corrected against the ref; the static byte reading, a cut where the freeze
// gate `0x5fd8e8` releases, could not reproduce the screen and is unconfirmed live).
// Only the swim re-latch holds — a ground landing, a water exit, or a new
// Special still cuts (0503's snapshot-freeze).
let swim_relatch_hold = sp == select::Special::Jump
@@ -266,10 +266,10 @@ pub(super) fn run(
} else if matches!(under, Some(select::Special::Jump | select::Special::Fall)) {
// The airborne-freeze (`0x5fd8e8` keep-current): mid-arc nothing re-picks
// bone 0 — a finished clip clamps and holds its last frame for the rest of
// the arc (the §6 clamp path), and a mid-air flag change is a keep-current
// the arc (the clamp at `0x7145db`), and a mid-air flag change is a keep-current
// no-op. The only exits are the edges above: the FALLINGFAR latch's Fall
// and the land pick at touchdown. This holds over Fall too: 0864's per-tick
// Fall(40) re-assert was §5-REFUTED (decision 0868 — Fall plays ONCE, at the
// Fall(40) re-assert was refuted (decision 0868 — Fall plays ONCE, at the
// latch edge `0x61a820@0x61a9eb`; `0x5ff030` is a wire-apply path, not a
// tick), so a clip that takes bone 0 after the latch holds until landing.
} else if let Some(sp) = under {
@@ -293,10 +293,9 @@ pub(super) fn run(
}
} else if oneshot_finished(player, anims, id, catalog) || mv.flags != drv.gait_flags {
// A finished one-shot recomputes the base — the ranged FIRE clips included
// (decision 1544, superseding 0994 §1). 0994 held them out on
// `shooter-stop-law.md` §J4's claim that the completion dispatcher `0x5fc3f0`
// is never reached for a bow id; wow-re's §5 refuted that as an absence proof
// whose census could not see its second fire site — the natural-completion path
// (decision 1544, superseding 0994 §1). 0994 held them out on the claim that
// the completion dispatcher `0x5fc3f0` is never reached for a bow id; it is,
// through a second fire site — the natural-completion path
// enqueues the callback as a plain ARGUMENT (`0x7194f5` pushes mode 0) and
// `0x7074b0` invokes it later as `call [esi+4]`, so a scan for
// `call dword ptr [reg+0x70]` misses it by construction. 46/49/107 land on the
@@ -367,7 +366,7 @@ pub(super) fn run(
);
drv.gait = None;
} else if airborne_frozen && drv.gait.is_some_and(|g| g != DEATH) {
// The airborne-freeze on the STEP-OFF arc — the exact §5-verified gate
// The airborne-freeze on the STEP-OFF arc — the exact gate
// (`0x5fd8e8`: `FALLING && (FALLINGFAR || vz ≠ 0)`, decisions 0864/0868;
// the selector chain's leg right after death): mid-air the selector never
// re-picks, so the takeoff-frozen gait keeps rolling mid-cycle AND the live
@@ -461,7 +460,7 @@ pub(super) fn run(
// (self: the latch; remote: the flag — the `looting` predicate above) on a
// stationary, unmounted unit, the gait slot holds Loot 50 — over the cast
// pin, the Ready/ranged idles, the chair loops and the state-emote idle
// alike (the `0x5fd8b0` chain order, §5-verified: locomotion → LOOT →
// alike (the `0x5fd8b0` chain order: locomotion → LOOT →
// standState → combat/channel). The trigger dropping cross-fades back to
// whatever the slot picks next.
let loot_cands;
@@ -492,7 +491,7 @@ pub(super) fn run(
};
let target = cands[0];
// **The turn-shuffle is released by its own clip window, not by the turn ending**
// (decision 1655, wow-re `object-layer/scratch/turn-shuffle-lifecycle.md`).
// (decision 1655).
//
// The client's only per-frame poll of a standing unit's base animation is
// `0x607ed0`'s tail, and it *refuses* this exact transition: with the shuffle
@@ -519,7 +518,7 @@ pub(super) fn run(
}
_ => target,
};
// Each RF-0057 candidate, in priority order, resolved through the model's own
// Each `0x5fd8b0` candidate, in priority order, resolved through the model's own
// baked fallback (decision 0082) before moving to the next candidate — a model
// missing the exact id still plays its baked substitute rather than stepping
// down the selector's own list early. The state-emote idle's id (above) resolves
@@ -537,8 +536,8 @@ pub(super) fn run(
// sweeping every node of the id. A completed ranged Load simply clamps at
// full draw and stays there; nothing promotes it (0994).
} else if let Some(c) = clip {
// **The two bypasses the reference keeps** (wow-re
// `base-anim-lock-knockdown.md` §6/§7.6): the death poses and the mount attach
// **The two bypasses the reference keeps** (`0x5fc563`,
// `0x607b44`): the death poses and the mount attach
// write the arm primitive directly and are not gated by the base-anim lock. In
// benilla both arrive as ordinary gait targets, so they say so here — a body
// knocked flat and then killed must still fall dead, and mounting during a
@@ -557,8 +556,8 @@ pub(super) fn run(
} else {
// A looping base arm rolls its variation when relaxed (decision 0123 —
// the client's base-arm `variationIdx = −1`; a combat/cast arm keeps the
// deterministic head) AND its replay budget (decision 0516 §7d — the
// watchdog window). A re-armed Stand landing on its rare look-around
// deterministic head) AND its replay budget (decision 0516, `0x712784`
// — the watchdog window). A re-armed Stand landing on its rare look-around
// variations IS the idle fidget.
let (c, budget) = roll_loop(anims, c, relaxed, rng);
if traced && benilla_assets::trace::enabled() {
@@ -618,7 +617,7 @@ pub(super) fn run(
}
/// Has the base slot's armed looping clip finished its **replay window** — the client's
/// `windowHi = windowLo + span·R` (decision 0516 §7d, wow-re `loop-replay-fidget.md` §7a)?
/// `windowHi = windowLo + span·R` (`0x712784`, decision 0516)?
///
/// `true` when there is no window to wait on at all: a slot with nothing armed, or one whose
/// window a newer arm superseded, is not something to hold a shuffle against. The driver's own
@@ -14,8 +14,7 @@ use super::super::{find_resolved, AnimDriver};
use super::select::{self, jump_land_pick, Mode, Special};
/// The **base-animation lock** — the reference's `[unit+0xd58]` bits `0xc0000`, which are the whole
/// reason a Lashed player visibly falls over (VERIFIED, wow-re `base-anim-lock-knockdown.md`;
/// decision 2096, correcting 2085's §3).
/// reason a Lashed player visibly falls over (decision 2096, correcting 2085's §3).
///
/// `CGUnit::PlayAnimation 0x5fe2f0` opens with a guard that returns having done nothing at all:
///
@@ -51,7 +50,7 @@ impl BaseAnimLock {
}
/// The guard: is a play refused right now? An **unconditional early return**, not a priority
/// comparison and not a dedup (`base-anim-lock-knockdown.md` §7.3).
/// comparison and not a dedup (`0x5fe3a1`).
pub(super) fn refuses(self) -> bool {
self.0.is_some()
}
@@ -66,7 +65,7 @@ impl BaseAnimLock {
/// The clearer, keyed on the **finished** id. Called once a frame before anything re-picks the
/// base, so completion and pre-emption both release it: an overridden clip's node still reports
/// finished at the end of its own span, which is exactly the reference's "the base stays locked
/// for the rest of the clip's window" when a mount arms over a knockdown (§7.6).
/// for the rest of the clip's window" when a mount arms over a knockdown (`0x607b44`).
pub(super) fn release_finished(
&mut self,
player: &AnimationPlayer,
@@ -80,12 +79,13 @@ impl BaseAnimLock {
}
}
/// Drop the lock outright — the reference's other two clearing sites (§7.5/§7.6).
/// Drop the lock outright — the reference's other two clearing sites.
///
/// A **model rebuild** needs no call here: benilla's rebuild removes [`AnimDriver`] itself
/// (`entities::live_display`), so the lock goes with it and the unit comes back with the base
/// unheld — which is the reference's own outcome at `0x60adee`, reached differently. What does
/// call it is the pair of arms the reference never gates: the death pose and the mount attach.
/// call it is the pair of arms the reference never gates: the death pose (`0x5fc563`) and the
/// mount attach (`0x607b44`).
pub(super) fn release(&mut self) {
self.0 = None;
}
@@ -191,7 +191,7 @@ pub(super) fn roll_oneshot<'a>(
}
/// Pick a looping arm's **variation** (decision 0123 — the client's base-arm `variationIdx = −1`,
/// wow-re `loop-replay-fidget.md` §5b): a **relaxed** arm makes the weighted `_rand` walk (the
/// `0x5fe697`): a **relaxed** arm makes the weighted `_rand` walk (the
/// same roll as a one-shot's — this is where a re-armed Stand lands on its rare look-around
/// variations, the fidget); a combat/cast arm is forced to the deterministic head
/// ([`select::arm_forces_head`] decides which, at the call site). The kernel itself never
@@ -213,8 +213,8 @@ pub(super) fn pick_loop_variation<'a>(
}
/// The looping arm's two rolls in op4's order (variation `0x71249a`, then the replay budget
/// `0x712692..` — the same two `_rand` sites as a one-shot's; decision 0516, wow-re
/// `loop-replay-fidget.md` §7d): the budget is live for **loops** too — not as a repeat cap but
/// `0x712692..` — the same two `_rand` sites as a one-shot's; decision 0516): the budget is live
/// for **loops** too — not as a repeat cap but
/// as the watchdog **window**, `R` clip-lengths wide (`windowHi = arm + span·R`). Returns the
/// armed clip + `R` = total passes before the watchdog re-arms (`R ∈ [min, max−1]` floored to 1
/// — `replayMax` is exclusive, and `(0,0)`/`(0,1)` both play exactly once, visibly).
@@ -246,7 +246,7 @@ fn stationary_cast_hold_stows_an_engaged_casters_weapon() {
/// The committed-move route: the hold loops masked on the torso over the gait, and its stow must
/// HOLD between plays — the reconcile is edge-triggered like the client's (`0x5fdf80` runs only
/// inside `PlayAnimation`, wow-re `sheath-policy.md`), so the base track's flags-less Run never
/// inside `PlayAnimation`), so the base track's flags-less Run never
/// re-draws mid-hold on the frames where nothing plays. This was the caster staff bug's shape:
/// the per-frame base-track re-assert yanked the weapon back out one frame after the retake.
#[test]
@@ -380,7 +380,7 @@ fn spawn_fidgeter(app: &mut App) -> (Entity, Vec<AnimationNodeIndex>) {
(unit, nodes)
}
/// The emergent idle fidget (decision 0123 — wow-re `loop-replay-fidget.md` §5b): a RELAXED base
/// The emergent idle fidget (decision 0123): a RELAXED base
/// arm rolls its variation (the client's `variationIdx = −1`), an engaged one is forced to the
/// deterministic head, and the idle re-face turn-shuffle ([`crate::net::FacingStep`]) drives the
/// Shuffle↔Stand churn whose every return to Stand re-rolls.
@@ -644,8 +644,8 @@ fn spell_flinch_picks_the_wound_by_engagement() {
assert_eq!(node(idle), Some(2), "unengaged: StandWound(8)");
}
/// The wound trigger's fourth entry gate (`0x60eaac`–`0x60eac8`, wow-re
/// `charproc-rate-override-wound-gate.md`; decision 2063): a CharProc-11 rate-override node on
/// The wound trigger's fourth entry gate (`0x60eaac`–`0x60eac8`; decision 2063): a CharProc-11
/// rate-override node on
/// the unit — what a freeze aura (Ice Block, Freezing Trap, petrify, web wrap) leaves attached —
/// refuses every flinch for its life. The gate is the node's *presence*: kit 3071's rate of 1.0
/// (no freeze at all) closes it exactly like the family's 0.0. A unit without the node flinches.
@@ -801,7 +801,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
}
/// A same-frame swing/kit-anim collision runs the client's COMBAT FAST-PATH (decision 0406,
/// wow-re `combat-anim-fastpath.md`): the requests replay in [`PlaySeq`] wire order, the FIRST
/// `0x5fe43c`–`0x5fe48b`): the requests replay in [`PlaySeq`] wire order, the FIRST
/// arms, and the second — combat over combat — does NOT overwrite it: the armed clip doubles
/// to 2× and the second parks in the deferred cache. Both wire orders keep the first arrival on
/// the body. The director's ref ground truth this pins: the Eviscerate spin survives the
@@ -1058,8 +1058,8 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() {
/// A stationary caster mouselook-turning: the chase-step TURN flag flickers at mouse-event
/// cadence (set on delta frames, clear on quiet ones — `drive_body_heading`'s fold), but the
/// client's cast pin tests `[9e8] & 0x20000f` — translation + swim, NEVER the turn bits (wow-re
/// `spell-visual-apply.md` §2.1, `move_flags::CAST_PIN_MOVE`) — so the full-body hold stays
/// client's cast pin tests `[9e8] & 0x20000f` — translation + swim, NEVER the turn bits
/// (`0x5fde80`, `move_flags::CAST_PIN_MOVE`) — so the full-body hold stays
/// pinned through the flap. Routing this through the one-shot mask (`0x20003f`) instead churned
/// the gait hold↔Shuffle on every mouse-delta frame — the frostbolt right-drag jitter
/// (decision 0491).
@@ -1415,7 +1415,7 @@ fn the_creep_vis_flag_prowls_the_body() {
assert_eq!(gait(&app), Some(5), "broken stealth runs again");
}
/// The looping-variation ADVANCE (decision 0516 — wow-re `loop-replay-fidget.md` §7/§7d, the
/// The looping-variation ADVANCE (decision 0516 — the
/// watchdog `0x719370`): a relaxed looping base arm installs a replay window (here `(1,1)` → one
/// pass exactly); each completed window re-arms the id through the weighted, MEMORYLESS variation
/// walk. Over a dozen windows both authored Stand variations must take the main slot — the
@@ -1574,9 +1574,9 @@ fn a_jump_out_of_a_micro_detachment_still_enters_the_jump_bracket() {
}
/// The control on the launch rule: a step-off fall must stay a step-off fall for its whole arc.
/// The gait freeze is the §5-verified behaviour (decision 0868) and the new edge must not reach
/// into it — only a rise *past* the threshold is a launch, and a fall only ever accelerates
/// downward.
/// The gait freeze (`0x5fd8e8`) is the reference's behaviour (decision 0868) and the new edge
/// must not reach into it — only a rise *past* the threshold is a launch, and a fall only ever
/// accelerates downward.
#[test]
fn a_deepening_step_off_fall_never_becomes_a_jump() {
let mut app = app();
@@ -1691,7 +1691,7 @@ fn landing_mid_cast_plays_the_land_pick() {
/// The FALLINGFAR latch mid-one-shot is an edge (`Jump → Fall`): the client plays Fall(40)
/// ONCE, on the substep it latches (`0x61a820` — the 0864-era per-tick re-assert was
/// §5-refuted, decision 0868), replacing the clip. A fresh cast armed AFTER the latch then
/// refuted, decision 0868), replacing the clip. A fresh cast armed AFTER the latch then
/// holds bone 0 like any other airborne one-shot, until the landing pick cuts it.
#[test]
fn a_cast_over_the_fall_loop_holds_until_landing() {
@@ -2061,8 +2061,8 @@ fn the_step_off_arc_freezes_the_gait_against_live_pins() {
app.update(); // settle: Stand
let gait = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().gait;
assert_eq!(gait(&app), Some(0));
// Step off a ledge (downward launch: no jump arc, no Special; vz ≠ 0 — the §5-verified
// freeze gate `FALLING && (FALLINGFAR || vz ≠ 0)`, decision 0868) …
// Step off a ledge (downward launch: no jump arc, no Special; vz ≠ 0 — the freeze gate
// `0x5fd8e8`, `FALLING && (FALLINGFAR || vz ≠ 0)`, decision 0868) …
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FALLING,
vertical_speed: -3.0,
@@ -2149,7 +2149,7 @@ fn a_midair_deferred_park_survives_the_level_and_dies_at_the_landing_play() {
}
/// The deferred cache's consuming read sits DOWNSTREAM of the airborne-freeze (`0x5fd392`
/// inside the `0x5fd360` recompute arm; §5-verified, decision 0868): a park made mid-arc is
/// inside the `0x5fd360` recompute arm; decision 0868): a park made mid-arc is
/// never consumed mid-air, even with the body free — it waits, and the landing edge's play
/// clears it.
#[test]
@@ -2194,8 +2194,8 @@ fn a_midair_park_is_not_consumed_before_landing() {
);
}
/// **The ranged→melee handoff at every landed swing** (`0x625829`, wow-re `sheath-policy.md` §1's
/// `0x6255b0` row) — the director's report: a bow drawn by a shot that never fired, then a melee
/// **The ranged→melee handoff at every landed swing** (`0x625829` in
/// `0x6255b0`) — the director's report: a bow drawn by a shot that never fired, then a melee
/// attack, and the swings keep coming out of the bow. The reconcile provably cannot fix it, and
/// this pins both halves: the CONTROL (a sword swing while ranged-drawn leaves the stance at 2 —
/// the client's melee force is gated `CUR != 2`, `0x5fe0f9`/`0x5fe13b`, so the ranged stance is
@@ -2272,7 +2272,7 @@ fn a_landed_swing_snaps_its_attacker_out_of_the_ranged_stance() {
"the reconcile alone never leaves the ranged stance — this is the bug's shape"
);
// The packet arm's snap (what `net::apply::combat::attacker_state` now writes beside the
// The packet arm's snap (what `creature_anim::net::attacker_state` now writes beside the
// swing): the sword comes out on the first landed blow.
app.world_mut().write_message(SheathRequest {
entity: unit,
@@ -2637,7 +2637,7 @@ fn a_scaled_creatures_walk_cycles_slower_than_an_unscaled_ones() {
/// The mounted half of the same law: the client's divisor reads the MOUNT model
/// (`[unit+0xdc] ?: [unit+0xd8]`), whose rendered scale is the rider's `OBJECT_FIELD_SCALE_X`
/// times the mount's own `CreatureDisplayInfo` column (wow-re `0x613ef0`). Our mount child carries
/// times the mount's own `CreatureDisplayInfo` column (`0x613ef0`). Our mount child carries
/// only that column on its transform, so the driver must compose the host's in — a 1.5× sabre
/// under a 2.0× rider divides by 3.0, not 1.5.
#[test]
@@ -3075,11 +3075,11 @@ fn the_weapon_visual_hold_alone_never_puts_a_shooter_in_the_drawn_idle() {
/// The mid-volley half of the same report ("when it's on and I'm running it keeps repeating the
/// aim animation weirdly") — **re-derived, and inverted, by decision 1544.**
///
/// 0994 read `shooter-stop-law.md` §J4 as: the completion dispatcher `0x5fc3f0` is never reached
/// for a bow id, so a finished AttackBow recomputes nothing and clamps on its tail. wow-re's §5
/// refuted that absence proof — the dispatcher has a SECOND, deferred fire site (`0x719370`
/// 0994 read the reference as: the completion dispatcher `0x5fc3f0` is never reached for a bow
/// id, so a finished AttackBow recomputes nothing and clamps on its tail. That absence proof is
/// wrong — the dispatcher has a SECOND, deferred fire site (`0x719370`
/// enqueues the callback as a plain argument with mode 0; `0x7074b0` invokes it later as
/// `call [esi+4]`, which an instruction-encoding census cannot see) — and decoded its jump table:
/// `call [esi+4]`, which an instruction-encoding census cannot see); its jump table:
/// 46/49/107 land on slot 22, a bare `RecomputeBaseAnim(-1)`, and a finished Load lands on slot
/// 11/12/15, which arms the Hold **unconditionally**.
///
@@ -3732,7 +3732,7 @@ fn a_disarmed_attacker_swings_and_stands_unarmed() {
/// The other rung of the ladder: with **no weapon in the main hand** the disarm falls to the off
/// hand, and that — not the dual-wield case — is what reaches AttackUnarmedOff(117) (decision
/// 1863; wow-re `disarm-weapon-gate-law.md` §7's table).
/// 1863; the swing selector `0x6246a0`).
#[test]
fn an_off_hand_only_fighter_is_the_case_that_punches_off_hand() {
let mut app = app();
@@ -3970,9 +3970,8 @@ fn a_no_wound_creature_takes_no_flinch() {
}
/// The flag's **second** consumer: `DO_NOT_PLAY_WOUND_ANIM` takes the victim's **parry** with its
/// flinch (`0x60ec1f` inside the parry pick `0x60ec00`, wow-re
/// `wound-parry-gate-and-injury-vocal.md` Q1/Q6 — the bit has exactly two callers and this is the
/// other one). The `$CPP` ladder enters `0x60ec00` only on victimState 3, so DODGE and BLOCK
/// flinch (`0x60ec1f` inside the parry pick `0x60ec00` — the bit has exactly two callers and this
/// is the other one). The `$CPP` ladder enters `0x60ec00` only on victimState 3, so DODGE and BLOCK
/// reach `PlayAnimation` directly and are **not** gated: a flagged creature still dodges, it just
/// never parries. Both halves are asserted here — the gate without its control is the bug this
/// whole record is about.
@@ -4074,9 +4073,8 @@ fn the_no_wound_flag_takes_the_parry_but_not_the_dodge() {
/// `0x602c60` → `0x5fd9e0` → `0x5fd8b0` → `0x5fd100`, and no locomotion id is in the combat set
/// that takes its no-latch fast path — so a unit that simply starts running consumes the pending
/// arm. That is what starts Charge's trail: kit 44's anim id is `-1`, `0x60f366 jl` skips the play
/// block entirely, and the arm waits for the charge's own run (wow-re
/// `charproc8-trail-draw-state.md` §11.5/§11.6). A one-shot-only edge would leave 38 Charge spells
/// with no trail at all.
/// block entirely, and the arm waits for the charge's own run. A one-shot-only edge would leave 38
/// Charge spells with no trail at all.
///
/// The other half matters as much: there is **no per-frame animation recompute** in the reference
/// (`0x5fd8b0` has one caller and `0x5fd9e0`'s 38 sites are all event-driven), so a unit standing
@@ -4127,8 +4125,7 @@ fn a_gait_change_raises_the_anim_edge_and_a_steady_frame_does_not() {
assert!(edge(&app), "and so does a one-shot");
}
/// **The combat fast path must NOT raise the anim edge** (decision 2076, wow-re
/// `format7-lighting-term.md`'s second question).
/// **The combat fast path must NOT raise the anim edge** (decision 2076).
///
/// `0x5fe43c` returns at `0x5fe48b` — *before* `0x5fe48e`, the weapon-trail latch's only read —
/// when the unit is already playing a combat animation and requests another one. So a pending
@@ -4192,7 +4189,7 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() {
);
}
/// The **base-animation lock** (decision 2096, VERIFIED wow-re `base-anim-lock-knockdown.md`) —
/// The **base-animation lock** (decision 2096) —
/// the reason a Lashed player visibly falls over, and the correction to 2085's reading.
///
/// A stun's root recomputes the base unconditionally and the selector resolves `Stand(0)`; what
@@ -65,7 +65,8 @@ pub(super) fn wound_upkeep(entity: Entity, drv: &mut AnimDriver, player: &mut An
/// the slot). So this frame's full-body plays (bone 0: a swing on the base, a gait/mode
/// change) evict a FULL-BODY wound, and masked-slot plays (the key-bone: a masked swing,
/// the cast-hold retake) evict a MASKED wound — while the *other* bone's plays leave the
/// wound decaying (§3's inherited-swing case: a full-body swing under a masked wound).
/// wound decaying (the kernel `0x714260`'s inherited-swing case: a full-body swing under a
/// masked wound).
/// This is exactly why the real client's flinch never smothers the next attack: the swing
/// reclaims the slot the instant it starts. Mode/gait changes proxy the mode machine's
/// plays — a change with no play (Land's re-pick) merely evicts one frame before the play
@@ -99,12 +100,12 @@ pub(super) fn wound_evict(
}
}
/// The victim wound flinch (decision 0111 — the §5 verdict, rebuilt from bytes after
/// The victim wound flinch (decision 0111 — rebuilt from bytes after
/// the first routing was director-falsified): a landed hit lays the wound clip into this
/// unit's **secondary slot** (`0x60ea70` → op4 `linkFlag=0`) — a decaying 0.75-amplitude
/// blend overlay over whatever plays. It never touches the base track or the one-shot
/// slot: the victim's own in-flight swing keeps running underneath (there is NO mid-swing
/// gate — both §5 trigger agents refuted that hypothesis), and the upkeep above blends it
/// gate), and the upkeep above blends it
/// out and self-releases — until a same-bone re-arm evicts it (the block above; a wound
/// triggered here is this frame's *last* write, matching the client's packet order).
/// The trigger's own entry gates live in the caller ([`super::drive_animations`]): the victim's

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