Merge upstream/main (samwhosung/benilla b17be271) into the graphics programme

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-26 21:28:31 +01:00
co-authored by Claude Opus 5.5
838 changed files with 48288 additions and 124310 deletions
+1 -1
View File
@@ -1,5 +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`.
# `release`). Env vars still prefix it: `WOW_USER=<account> WOW_PASS=<password> cargo play`.
[alias]
play = "run --profile play -p benilla"
+2
View File
@@ -1,5 +1,7 @@
<!-- One change per pull request. docs/CONTRIBUTING.md says what gets in. -->
**The issue it fixes** (`Fixes #N`, if there is one)
**What this changes**
**What 1.12.1 does, and how you know** (the client's behaviour, a DBC field, a FrameXML line, a capture)
+54
View File
@@ -0,0 +1,54 @@
# The gates of scripts/gates.sh that need neither a WoW install nor a display, on Linux, for a pull
# request from a fork: a red one is seen before anyone builds it. The data-gated tests, the engine
# boot checks and a live run happen on the maintainer's machine before it lands.
name: CI
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
gates:
# A branch of this repository runs the full chain before it lands; a fork's pull request runs here.
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
timeout-minutes: 150
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# No debuginfo: it is most of target/, and a runner's disk is small.
CARGO_PROFILE_DEV_DEBUG: 0
# Set and empty: no install, as the gates' test-no-install runs.
WOW_DATA: ""
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Free disk space
run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
- name: System libraries
run: sudo apt-get update -qq && sudo apt-get install -y -qq pkg-config libasound2-dev libudev-dev
- name: Toolchain
run: rustup toolchain install && rustc -V
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
run: cargo test --workspace
- name: doc-links
run: scripts/doc-links.py
- name: pass-span-lint
run: scripts/pass-span-lint.py
- name: player-build
run: cargo build -p benilla --no-default-features
- name: player-tests
run: cargo test -p benilla-formats -p benilla-app --no-default-features --lib
+66 -52
View File
@@ -1,6 +1,6 @@
<div align="center">
<h1>benilla</h1>
<p><b>A from-scratch World of Warcraft 1.12.1 client in Rust and <a href="https://bevy.org">Bevy</a></b></p>
<p><b>A complete World of Warcraft 1.12.1 client, written from scratch in Rust and <a href="https://bevy.org">Bevy</a></b></p>
<p>
<a href="https://discord.gg/wJSJx467G4"><img src="https://img.shields.io/discord/1529280129518538922?style=for-the-badge&logo=discord&logoColor=white&label=discord&color=5865F2" alt="Discord"></a>
<a href="https://www.youtube.com/playlist?list=PLdCnpZNKxyb8"><img src="https://img.shields.io/badge/devlog-youtube-FF0000?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube devlog"></a>
@@ -8,73 +8,87 @@
</p>
</div>
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,
and no bundled game assets.
benilla plays the whole game: character creation, questing and professions, dungeons and raids,
battlegrounds and honor, groups, guilds, trade, mail and the auction house, on the stock 1.12
interface and with your addons. It connects to a 1.12.1 server over the original protocol and
reads the game's data from your own 1.12.1 install. Every file format, the network protocol and
the interface engine are written from scratch, with no original client code, no third-party WoW
crates and no bundled game assets.
## What works
## What's inside
- **Formats:** readers for the full asset stack (MPQ patch chain, BLP, DBC, ADT/WDT/WDL, M2, WMO),
wired into Bevy as an asset source.
- **World:** streamed terrain out to the horizon, portal-culled WMOs with interior lighting,
doodads and ground clutter, swimmable liquids, sky and weather, and the client's own day/night
lighting, fog and gamma passes.
- **Models:** GPU-skinned M2s with the full animation controller, a near feature-complete particle
system, ribbons, and animated gameobjects from doors to lifts.
- **Characters:** customization end to end, the armor texture composite, weapons with sheathing and
enchant glows, shapeshift forms, stealth and mounts.
- **Movement:** a WoW-feel controller, networked movement in both directions, the server-granted
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, 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
interior and underwater transitions and zone reverb.
- **Formats:** readers for the whole asset stack (the MPQ patch chain, BLP, DBC, ADT, WDT, WDL, M2
and WMO), wired into Bevy as an asset source.
- **World:** terrain streamed out to the horizon, portal-culled buildings with interior lighting,
doodads and ground clutter, swimmable water, sky and weather, and the client's own day/night
lighting, fog and gamma.
- **Models:** GPU-skinned M2s with the full animation controller, particles, ribbons and the spell
visuals, and characters end to end: customization, the armor composite, weapons with their
enchant glows, forms, stealth and mounts.
- **Movement:** networked movement in both directions, the server-granted modes from slow fall to
roots, a follow camera with collision, boats, zeppelins and taxi flights.
- **Networking:** SRP6 auth through world-session crypto and the object mirror into the ECS,
covering the game from movement and chat through combat, spells, groups and raids, quests,
trade, mail, the auction house and battlegrounds.
- **Interface:** a FrameXML and Lua engine that runs the stock 1.12 interface off your install's
patch chain, and third-party 1.12 addons: Questie, pfUI, Bagnon, Bartender2 and most others
run. By choice, the options window and the ESC menu follow the Classic Era client's rather than
1.12's.
- **Audio:** music, ambience and effects under the client's own selection and crossfade rules,
with interior and underwater transitions and zone reverb.
## Where it's going
The format readers and the UI engine core are plain Rust with no Bevy in them, and the world
renderer runs with no game attached. [`docs/MAP.md`](docs/MAP.md) maps every crate and subsystem,
generated from the code.
benilla is done when a 1.12.1 player can do everything here that they could in the original
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.
## Status
- The long tail of small features that separates a working client from a finished one.
Complete and fully playable. What is left:
- The long tail of small features that separates a working client from a finished one, tracked
as [issues](https://github.com/samwhosung/benilla/issues).
- Addons, options and performance, ongoing.
- Playable downloads for Windows, Linux and macOS. Linux first.
benilla is a faithful 1.12.1 client and the foundation people build on. There are no prebuilt
downloads: a packaged build, a particular server's changes or anything 1.12.1 never had belongs
in a fork, and forks are welcome. GitHub lists
[every public fork](https://github.com/samwhosung/benilla/forks).
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,
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.
benilla builds and runs on macOS, Linux and Windows. You need:
- **An English 1.12.1 client (build 5875)** for the game data. benilla only reads it.
- **A 1.12.1 server with Warden off.** [vmangos](https://github.com/vmangos/core) is what
development runs against, and it ships with Warden off; cMaNGOS and the other 1.12.1 cores speak
the same protocol.
- **Stable Rust and a C compiler**, because the client's Lua is built from source: on macOS the
Xcode command line tools, on Linux the ALSA and udev development packages and pkg-config, on
Windows the MSVC build tools that the Rust installer sets up.
```sh
WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla
```
The server defaults to `localhost:3724`, the stock `realmd` auth port. Point `WOW_HOST`
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.
`WOW_DATA` names the install's `Data` folder; a link to the install named `WoW` at the repo root
does the same. The server defaults to `localhost:3724`, the stock auth port. Point `WOW_HOST` at
another (`WOW_HOST=play.example.com`, or `play.example.com:5000` for a remapped port), or set it
from the Realmlist button on the login screen, which remembers it. Credentials go in at the login
screen, or set `WOW_USER` and `WOW_PASS` to skip it.
Settings, screenshots and addons live in `benilla-config/` at the repo root: a 1.12 addon goes in
`benilla-config/AddOns/`. [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) has the rest, from the
player build to the tests.
Optional, locally built skybox and colour-grading data: [`Optional/sky-and-grading/`](Optional/sky-and-grading/README.md).
## 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.
Issues and pull requests are open. [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) says where to
start, what gets in, how a change is judged and what happens to a pull request once it is open.
Bugs, questions and ideas are welcome on the [Discord](https://discord.gg/wJSJx467G4) too.
---
@@ -83,13 +97,13 @@ Early inspiration and file format guidance came from the
[warcraft-rs](https://github.com/wowemulation-dev/warcraft-rs) in particular.
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 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.
It ships **no Blizzard content**: no art, models, sounds, maps, MPQ contents or FrameXML. You
provide your own legally obtained 1.12.1 client, and the stock interface runs off its FrameXML at
runtime. The few files under `crates/benilla-app/assets/ui/` are our own, not copies of it:
adapters over stock files, and the settings windows and script error log benilla draws itself.
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. The two vendored components
under `third_party/` — the kira audio engine, and a Lua 5.1 patched to the 1.12 client's dialect —
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. Code and techniques ported from other projects
(WarcraftXL, by iThorgrim) are credited file by file in [`THIRD-PARTY.md`](THIRD-PARTY.md).
+19 -31
View File
@@ -9,40 +9,34 @@ description = "The Bevy app as a library: everything but the build-id stamp, whi
workspace = true
[features]
# `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.
# `dev` compiles in the instruments; the player build (`scripts/gates.sh`) has no defaults.
default = ["dev"]
# `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.
# The asset, format and world crates default `dev` on, so they come in without it, forwarded here.
dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:bevy_egui"]
tracy = ["bevy/trace_tracy"]
# `--features trace_chrome` writes every system's per-run span to `trace-<ts>.json` in the cwd.
# Writes every system's per-run span to `trace-<ts>.json` in the cwd.
trace_chrome = ["bevy/trace_chrome"]
[dependencies]
anyhow.workspace = true
# 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.
# The EditBox clipboard, on the main thread as NSPasteboard requires; bevy_egui's 3.x.
arboard = "3"
benilla-ui = { path = "../benilla-ui" }
bevy.workspace = true
wgpu.workspace = true
bevy_egui = { version = "0.39", optional = true }
bytemuck = { version = "1", features = ["derive"] }
# `f16` decode for the `WOW_BOOTH_DUMP` dump of the `Rgba16Float` booth render targets.
# `f16` decode for the `WOW_BOOTH_DUMP` dump of the `Rgba16Float` booth targets.
half = "2"
# 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 (`text_filter`): the DBCs' 2289 profanity and 28 spam patterns, with one
# `RegexSet` for the any-match pass over every chat line.
# The 1.12 chat filters (`text_filter`), with one `RegexSet` for the any-match pass per line.
regex = "1"
# `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.
# `getrusage` for perf CPU time; the audio clock and render thread's realtime policy on macOS.
libc = "0.2"
# PNG encoding only: the macOS hardware cursor's NSImage data and screenshots.
# PNG encoding only: the macOS cursor's NSImage data and screenshots.
image = { version = "0.25", default-features = false, features = ["png"] }
# 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.
# Thread-to-ECS channels: crossbeam's `Receiver` is `Sync`, so it is a resource without a mutex.
crossbeam-channel = "0.5"
benilla-assets = { path = "../benilla-assets", default-features = false }
benilla-formats = { path = "../benilla-formats", default-features = false }
@@ -55,33 +49,29 @@ toml = "0.8"
avian3d = "0.6"
# Shapes and rasterizes `FontString` text standalone (`ui_text`), at bevy_text's own 0.16.
cosmic-text = "0.16"
# `cosmic-text`'s font database, whose types `ui_text` names; must stay the version it depends on.
# `cosmic-text`'s font database, named by `ui_text`; must stay the version it depends on.
fontdb = "0.23"
# 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"] }
# Aborts a debug build on an allocation in the audio IO callback or render pass; off in release.
assert_no_alloc = "1.1"
# 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.
# `MediaSource`: our source raises kira's decode thread's QoS; must stay kira's version.
symphonia = { version = "0.5", default-features = false }
# 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's weighted draw, re-rolled per open as in the reference; `benilla-srp`'s 0.8.
# The gossip greeting's weighted draw, re-rolled per open as in the reference.
rand = "0.8"
# 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.
# Wayland clipboard over the standard `wl_data_device`; arboard's 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: 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`).
# A native NSCursor: winit's cursor rects revert to the arrow under a continuous render loop.
[target.'cfg(target_os = "macos")'.dependencies]
# 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.
# The audio device layer (`sound::output::coreaudio`), 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"] }
@@ -89,7 +79,6 @@ objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = [
"NSApplication",
"NSResponder",
# NSApplicationActivationPolicy (bgwin's Accessory demotion) is declared in this header.
"NSRunningApplication",
"NSWindow",
"NSView",
@@ -99,12 +88,11 @@ objc2-app-kit = { version = "0.2", features = [
] }
objc2-foundation = { version = "0.2", features = ["NSArray", "NSData", "NSGeometry"] }
# 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.
# The audio device layer off macOS (`sound::output::cpal`); on macOS cpal cannot rebuild on a
# lost default device and drops the IO-cycle timestamp.
[target.'cfg(not(target_os = "macos"))'.dependencies]
cpal = "0.17"
# Windows CPU clocks for `perf::clock`: `GetProcessTimes`, `GetThreadTimes`, `GetSystemTimes`.
# Windows CPU clocks for `perf::clock`.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
@@ -1,19 +1,13 @@
<!--
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.
Adapters over the stock ContainerFrame.xml and .lua; loaded after them, so these win.
Deviation: our keyring is a container (id -2), not inventory slots as in the reference, so its
tooltip comes from `SetBagItem`; the stock fork (ContainerFrame.lua:615-620) reads the slots.
A full keyring prints an empty error line as the reference does: `PutKeyInKeyRing` reads
`NO_EMPTY_KEYRING_SLOTS`, which 1.12 does not ship.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- 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.
-- The stock body, captured before it is shadowed; nil when no client data was sourced.
local refItemButtonOnEnter = ContainerFrameItemButton_OnEnter
function ContainerFrameItemButton_OnEnter(button)
@@ -22,8 +16,7 @@
end
if not button then return end
-- 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.
-- The stock bag-slot path, not its inventory-slot keyring detour.
if button:GetParent():GetID() == KEYRING_CONTAINER then
if (button:GetRight() or 0) >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
@@ -44,29 +37,22 @@
end
-- ══ 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.
-- Deviation: `OpenBackpack` opens every equipped bag, not only the backpack, so the vendor,
-- bank, mail and trade windows that call it bring up every bag to sell or deposit from.
-- The was-open memory is keyed by bag id: `ContainerFrame1..12` are recycled across bags.
BENILLA_BAG_WAS_OPEN = {}
-- 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).
-- Ignores the was-open memory and covers the bank bags, as ContainerFrame.lua:702-707 does.
function BenillaCloseAllBags()
for bagId = 0, (NUM_CONTAINER_FRAMES or -1) do
CloseBag(bagId)
end
end
-- 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.
-- The reference's `backpackWasOpen`, one entry per bag: CloseBackpack leaves those open.
function BenillaOpenBackpack()
for bagId = 0, (NUM_BAG_FRAMES or -1) do
-- OpenBag's own test, repeated so the memory agrees with it: the backpack always,
-- another bag only when its slot holds one.
-- OpenBag's own test, repeated so the memory agrees with it.
if bagId == 0 or GetContainerNumSlots(bagId) > 0 then
if IsBagOpen(bagId) then
BENILLA_BAG_WAS_OPEN[bagId] = 1
@@ -86,8 +72,7 @@
end
end
-- 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`).
-- After the stock ContainerFrame.lua has loaded, so its callers reach these.
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
+23 -48
View File
@@ -1,17 +1,9 @@
<Ui>
<!--
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.
The game menu ESC opens, in the chrome of the reference's GameMenuFrame.xml.
Deviation: the rungs are the 1.15 era client's ESC menu, because its settings screens
replace 1.12's, which are much worse to use; the era's AddOns rung is dropped, since the
character-select AddOns screen is the only addon UI. Addons address `GameMenuFrame` by name.
-->
<Script><![CDATA[
-- Label strings as `X = X or "..."`, so a stock GlobalStrings value wins where one exists.
@@ -24,18 +16,15 @@
RETURN_TO_GAME = RETURN_TO_GAME or "Return to Game"
CANCEL = CANCEL or "Cancel"
-- 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.
-- A rung with nothing behind it yet (Edit Mode, Support), disabled.
function GameMenuButton_Pending(button, text)
button:SetText(text)
button:Disable()
end
-- ══ 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.
-- Each wraps the stock body to add `BenillaOptionsFrame`; here because this file loads
-- last, after MainMenuBarMicroButtons.xml.
local Ref_IsOptionFrameOpen = IsOptionFrameOpen
local Ref_ToggleGameMenu = ToggleGameMenu
local Ref_UpdateMicroButtons = UpdateMicroButtons
@@ -45,8 +34,7 @@
return BenillaOptionsFrame and BenillaOptionsFrame:IsVisible()
end
-- `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.
-- `UIParent.lua:996`: a yes keeps the ESC sweep off the centre panel, which our window is.
function IsOptionFrameOpen()
if BenillaOptions_IsOpen() then
return 1
@@ -54,13 +42,11 @@
return Ref_IsOptionFrameOpen()
end
-- `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()`.
-- `UIParent.lua:1465-1497`: static popup, then an open options window, then the stock
-- ladder. Our Close click is the discard-and-hide of the reference's `OptionsFrameCancel`.
function ToggleGameMenu(clicked)
if clicked then
-- As the reference's `clicked` arm: close an options window, then still toggle.
-- As the reference's `clicked` arm: close an options window, then toggle.
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
end
@@ -76,9 +62,8 @@
return Ref_ToggleGameMenu(clicked)
end
-- `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.
-- `MainMenuBarMicroButtons.lua:47-58`: only ever pushes the button, so the stock decision
-- stands and ours adds our window.
function UpdateMicroButtons()
if Ref_UpdateMicroButtons then
Ref_UpdateMicroButtons()
@@ -90,18 +75,15 @@
]]></Script>
<!-- The era's header font, GameFontNormalMed1 (FRIZQT 13, gold): an era name 1.12's Fonts.xml
lacks, declared beside its one consumer. -->
<!-- The era's header font, which 1.12's Fonts.xml lacks. -->
<Font name="GameFontNormalMed1" inherits="GameFontNormal" virtual="true">
<FontHeight><AbsValue val="13"/></FontHeight>
</Font>
<!-- The reference frame's attributes (GameMenuFrame.xml l.3) except `movable`, which is inert
there: the reference defines no drag scripts. -->
<!-- GameMenuFrame.xml:4's attributes but `movable`, inert there: it has no drag scripts. -->
<Frame name="GameMenuFrame" parent="UIParent" frameStrata="DIALOG" toplevel="true" enableMouse="true" hidden="true">
<!-- The era layout: 28+144+28 wide; 32 top and 28 bottom padding around seven 21-unit
rungs and three 20-unit section gaps. -->
<!-- The era layout: 28+144+28 wide, 32 top, 28 bottom, 7 rungs of 21, 3 gaps of 20. -->
<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">
@@ -115,8 +97,7 @@
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="12"/></Offset></Anchor></Anchors>
</Texture>
<!-- The era's 13px header font (MainMenuFrameMixin:OnLoad) on the 1.12 seat, which
is within half a unit of the era's. -->
<!-- The era's 13px font on the 1.12 seat, 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>
@@ -126,9 +107,7 @@
</Layers>
<Frames>
<!-- 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". -->
<!-- Literal: 1.12 has no GAMEMENU_OPTIONS; its OPTIONS_MENU is "Options Menu". -->
<Button name="GameMenuButtonOptions" inherits="GameMenuButtonTemplate" text="Options">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="28" y="-32"/></Offset></Anchor>
@@ -158,7 +137,7 @@
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonSupport" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<!-- The reference's body (GameMenuFrame.xml l.118-122). -->
<!-- GameMenuFrame.xml:118-122. -->
<Scripts>
<OnClick>
PlaySound("igMainMenuOption")
@@ -168,8 +147,7 @@
</Scripts>
</Button>
<!-- 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. -->
<!-- As the reference: disabled while a CAMP or QUIT countdown is up. -->
<Button name="GameMenuButtonLogout" inherits="GameMenuButtonTemplate" text="LOGOUT">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonMacros" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
@@ -220,14 +198,11 @@
</Scripts>
</Button>
</Frames>
<!-- 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. -->
<!-- The reference's OnShow/OnHide, guarded for a test that loads no action bar. -->
<Scripts>
<OnShow>
-- 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.
-- ERA_WINDOW_SCALE (OptionsFrame.xml) stands in for the era's UIParent scale. A
-- second OnShow on this frame would replace this one.
this:SetScale(ERA_WINDOW_SCALE or 1)
if UpdateMicroButtons then UpdateMicroButtons() end
if Disable_BagButtons then Disable_BagButtons() end
@@ -1,24 +1,16 @@
<Ui>
<!--
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).
The options window's Keybindings page: templates and script; its rows live in
OptionsFrame.xml, which loads after. Only commands the engine dispatches appear.
Deviation: the layout is the era Settings panel's Keybindings page (collapsible category
sections), because 1.12's settings screens are much worse to use. Capture follows 1.12's
Blizzard_BindingUI: the next key or mouse 3/4/5 press binds, left and right clicks only
select, and ESC while armed binds Escape (its OnKeyDown has no ESCAPE filter).
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.
-->
<!-- 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. -->
<!-- 1.12's KeyBindingFrameBindingButtonTemplate; all five buttons reach the capture. -->
<Button name="KeyBindingButtonTemplate" virtual="true">
<Size><AbsDimension x="180" y="22"/></Size>
<ButtonText name="$parentText"/>
@@ -34,10 +26,8 @@
</Scripts>
</Button>
<!-- 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). -->
<!-- A binding row or a section header in 1.12's QuestLogTitleButton dress, since the era's
expand arrows are not in 1.12; 25 tall, the era's row height. -->
<Frame name="KeyBindingRowTemplate" virtual="true">
<Size><AbsDimension x="560" y="25"/></Size>
<Layers>
@@ -91,10 +81,8 @@
return fallback
end
-- ── 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.
-- ── Key display ── 1.12's GetBindingText in full form: modifier prefixes stay tokens, the
-- base key reads its KEY_* global. "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
@@ -119,9 +107,7 @@
return prefix .. KeyBindings_String("KEY_" .. rest, rest)
end
-- ── 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).
-- ── Page state ── `searchPainted` maps OptionsFrame.xml's search rows to binding indices.
KeyBindingsPage = { sections = {}, items = {} }
KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
@@ -129,9 +115,8 @@
KEYBINDINGS_ROW_HEIGHT = 25
KEYBINDINGS_SEARCH_ROWS = 8
-- ── 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.
-- ── Section model ── one section per category in Bindings.xml order, collapsed at first
-- as in the era; a rebuild keeps each state.
function KeyBindings_BuildSections()
local page = KeyBindingsPage
local was = {}
@@ -153,8 +138,7 @@
KeyBindings_Flatten()
end
-- The visible list the faux scroll runs over (the era's EvaluateVisibility): each header,
-- then its rows when expanded.
-- The visible list the faux scroll runs over.
function KeyBindings_Flatten()
local page = KeyBindingsPage
page.items = {}
@@ -168,7 +152,7 @@
end
end
-- A header click (the era's OnExpandedChanged), voiced with the checkbox sound.
-- A header click, voiced with the checkbox sound.
function KeyBindings_ToggleSection(n)
local sec = KeyBindingsPage.sections[n]
if not sec then return end
@@ -178,7 +162,7 @@
KeyBindingsPage_Update()
end
-- Expands or collapses a section silently; the capture fixture opens sections with it.
-- Silent, for the capture fixture.
function KeyBindings_ExpandSection(n, on)
local sec = KeyBindingsPage.sections[n]
if not sec then return end
@@ -186,8 +170,7 @@
KeyBindings_Flatten()
end
-- ── 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.
-- ── Painters ── shared by the list and the search rows, which capture in place.
function KeyBindings_PaintBindRow(name, index)
local command, _, key1, key2 = GetBinding(index)
local b1 = getglobal(name .. "Key1Button")
@@ -234,7 +217,7 @@
getglobal(name .. "Key2Button"):Hide()
end
-- ── The list repaint (1.12 KeyBindingFrame_Update over the era section list). ──
-- ── The list repaint (1.12 KeyBindingFrame_Update) ──
function KeyBindingsPage_Update()
local page = KeyBindingsPage
local scroll = getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrame")
@@ -255,14 +238,12 @@
end
FauxScrollFrame_Update(scroll, table.getn(page.items),
KEYBINDINGS_DISPLAYED, KEYBINDINGS_ROW_HEIGHT)
-- The trough is ours and the stock kit never shows one, so it follows the bar
-- `FauxScrollFrame_Update` has just shown or hidden.
-- Our trough follows the bar `FauxScrollFrame_Update` has just shown or hidden.
BenillaScrollTrough_Follow(scroll:GetName())
KeyBindings_UpdateUnbindKey()
end
-- 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.
-- The page entry: a rebind or a set switch may have changed the table.
function KeyBindingsPage_Refresh()
KeyBindings_BuildSections()
local box = getglobal("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck")
@@ -279,9 +260,7 @@
getglobal("BenillaOptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
end
-- ── Selection ── selecting a capsule arms the host's capture (BenillaBindCapture), which
-- swallows raw input and hands the canonical chord to KeyBindings_OnHostKey; deselecting
-- disarms.
-- ── Selection ── arms the host's capture, which hands its chord to KeyBindings_OnHostKey.
function KeyBindings_SetSelected(command, keyID)
KeyBindingsPage.selected = command
KeyBindingsPage.keyID = keyID
@@ -297,8 +276,7 @@
end
end
-- 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.
-- Every search row: a bind from one can unbind a victim shown on another.
function KeyBindings_RepaintVisible()
if KeyBindingsPage.searchPainted then
for name, index in pairs(KeyBindingsPage.searchPainted) do
@@ -311,9 +289,7 @@
end
function KeyBindingButton_OnClick(button)
-- 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.
-- As 1.12: while armed, a middle, 4 or 5 press is a bind the capture takes after this.
local page = KeyBindingsPage
if page.selected then
if button ~= "LeftButton" and button ~= "RightButton" then
@@ -337,14 +313,11 @@
KeyBindings_RepaintVisible()
end
-- 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` 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.
-- `chord` is ALT-CTRL-SHIFT-TOKEN; 1.12's OnKeyDown armed branch, then the live commit.
function KeyBindings_OnHostKey(chord)
local page = KeyBindingsPage
if not page.selected then
@@ -379,9 +352,7 @@
KeyBindings_RepaintVisible()
end
-- 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.
-- 1.12's KeyBindingFrame_SetBinding, message and all; SetBinding refuses only a non-key.
function KeyBindings_SetOrRestore(key, command, oldKey)
if not SetBinding(key, command) then
if oldKey then SetBinding(oldKey, command) end
@@ -391,8 +362,7 @@
end
end
-- 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.
-- 1.12's Unbind Key body, then the live commit.
function KeyBindings_Unbind()
local page = KeyBindingsPage
local key1, key2 = GetBindingKey(page.selected)
@@ -409,12 +379,9 @@
KeyBindings_RepaintVisible()
end
-- ── 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.
-- ── Character-specific set ── checking saves set 2 at once (LoadBindings(2) falls back
-- to the account set); unchecking confirms first, as the era does. The accept loads set 1
-- before saving it, or the character binds would be written into the account set.
function KeyBindings_CharacterToggle(box)
PlaySound("igMainMenuOptionCheckBoxOn")
if box:GetChecked() then
@@ -442,8 +409,7 @@
KeyBindingsPage_Refresh()
end,
}
-- 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.
-- Reset To Default confirms, as the era's does; our string, since 1.12 has none.
StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
button1 = KeyBindings_String("OKAY", "Okay"),
@@ -458,11 +424,8 @@
end,
}
-- ── 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.
-- ── Search ── a binding's tag is its display name uppercased, as the era's.
-- 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
+27 -49
View File
@@ -1,31 +1,24 @@
<Ui>
<!--
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.
The script error log, which 1.12 does not have, so every name carries the `Benilla` prefix
(`tests::reference_surface`); the stock `ScriptErrors` dialog shows one message, this keeps
the log. Shaped like the reference's CraftFrame. It polls the engine:
`BenillaGetNumScriptErrors()` is (retained, total-ever), `BenillaGetScriptErrorInfo(i)` is
`seq, kind, message, count` (1-based, oldest first, nil past the end).
-->
<Script><![CDATA[
-- A left-slot window with a pushable above every stock panel's, so a game window opening
-- pushes the log aside.
-- 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; a wrapped row would break the faux kit's fixed rows.
-- A wrapped row would break the faux kit's fixed rows.
BENILLA_SCRIPTLOG_ROW_CHARS = 52
-- The selected row's `seq`, not its index: indices shift when the cap evicts.
-- A `seq`, not an index: indices shift when the cap evicts.
BenillaScriptLogSelected = nil
-- 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.
-- `strsub` counts bytes, so the cut backs up over UTF-8 continuation bytes (0x80..0xBF).
function BenillaScriptLog_RowText(message)
local cut = strfind(message, "\n", 1, 1)
local head = message
@@ -42,10 +35,8 @@
return head
end
-- 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
-- `load` red (the addon is not running), `error` amber (code raised), `warn` grey (a call
-- was accepted and quietly did nothing).
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
@@ -55,8 +46,7 @@
function BenillaScriptLog_Update()
local shown, total = BenillaGetNumScriptErrors()
-- `total` counts distinct failures ever recorded, `shown` what the cap kept. The
-- summary says "problems" because two of the three kinds are not errors.
-- "Problems", because two of the three kinds are not errors.
local summary
if total == 0 then
summary = "No problems this session."
@@ -97,8 +87,7 @@
end
end
-- The highlight follows the selected row and hides when it scrolls away, as the
-- reference's CraftFrame does.
-- Hides when the row scrolls away, as the reference's CraftFrame does.
local highlight = getglobal("BenillaScriptLogHighlightFrame")
if selectedRow then
highlight:SetPoint("TOPLEFT", selectedRow:GetName(), "TOPLEFT", 0, 0)
@@ -110,8 +99,7 @@
BenillaScriptLog_UpdateDetail()
end
-- The selected message in full, traceback and all; selection is a `seq`, so this walks
-- the log rather than indexing it.
-- The selected message in full, traceback and all.
function BenillaScriptLog_UpdateDetail()
local text = ""
if BenillaScriptLogSelected then
@@ -130,8 +118,7 @@
getglobal("BenillaScriptLogDetailText"):SetText(text)
end
-- 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.
-- Text rects resolve after Lua runs, so the detail child is re-measured every frame.
function BenillaScriptLogFrame_OnUpdate()
BenillaScroll_ResizeChild("BenillaScriptLogDetailScroll", { "BenillaScriptLogDetailText" })
end
@@ -152,7 +139,7 @@
"Interface\\Icons\\INV_Misc_Note_01")
end
-- Stepping the bar repaints through the list frame's OnVerticalScroll, so no repaint here.
-- The list frame's OnVerticalScroll repaints.
function BenillaScriptLogFrame_OnMouseWheel(delta)
BenillaFauxScrollFrame_OnMouseWheel(getglobal("BenillaScriptLogListScrollFrame"), delta)
end
@@ -168,8 +155,7 @@
end
end
-- `/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.
-- `/errors clear`: keeps the numbering, so a number never names two failures.
function BenillaScriptLog_Clear()
BenillaClearScriptErrors()
BenillaScriptLogSelected = nil
@@ -180,8 +166,7 @@
end
]]></Script>
<!-- One list row: the `#N xCount` index column, then the message head, in fixed columns as
1.12's lists are. -->
<!-- One list row: the `#N xCount` column, then the message head. -->
<Button name="BenillaScriptLogRowTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
@@ -207,19 +192,17 @@
</Button>
<Frame name="BenillaScriptLogFrame" hidden="true">
<!-- 384x512, the standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<Layer level="BACKGROUND">
<!-- A square item icon: OnLoad masks it with `SetPortraitToTexture`, since `file=`
alone shows its corners through the ring. -->
<!-- OnLoad masks the square icon, whose corners would show 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 ClassTrainer quadrants, the one uniform set in the 1.12 art; this window
belongs to no profession. -->
<!-- The ClassTrainer quadrants, the one profession-free set in the 1.12 art. -->
<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>
@@ -228,7 +211,6 @@
<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, 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>
@@ -266,19 +248,17 @@
<Button name="BenillaScriptLogRow12" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow11" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow13" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow12" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<!-- The faux kit over the 13-row span; only its right-hand Slider renders. -->
<!-- Only the faux kit's right-hand Slider renders. -->
<ScrollFrame name="BenillaScriptLogListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="208"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
<Scripts>
<!-- The stock kit sets the offset only from here and has no `updateFunc`, so
the repaint is wired here too. -->
<!-- The stock kit has no `updateFunc`, so the repaint is wired here. -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_SCRIPTLOG_ROW_HEIGHT, BenillaScriptLog_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The detail pane, on the real-scroll kit: a message with its `stack traceback:` tail
has variable height. -->
<!-- The real-scroll kit: a message with its traceback 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>
@@ -325,16 +305,14 @@
</Frames>
<Scripts>
<OnLoad>BenillaScriptLogFrame_OnLoad(self)</OnLoad>
<!-- 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. -->
<!-- A spin bubbles up the parent chain; the detail pane's own handler wins there. -->
<OnMouseWheel>BenillaScriptLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>BenillaScriptLog_Update()</OnShow>
<OnUpdate>BenillaScriptLogFrame_OnUpdate()</OnUpdate>
</Scripts>
</Frame>
<!-- 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. -->
<!-- Benilla's slash commands, handed to SubmitChatInput (ui_chat::input); loads after the
stock ChatFrame.xml, which declares SlashCmdList. -->
<Script><![CDATA[
local BENILLA_COMMANDS = {
{ "CHATTEST", "/chattest" },
@@ -1,23 +1,15 @@
<Ui>
<!--
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.
Benilla's scroll parts: a `Slider` bar with its arrow helpers, an optional trough, and a
real-scroll kit that pans a `<ScrollFrame>` child whose height is known only after layout.
Faux lists use the stock `FauxScrollFrameTemplate`; this file declares only `Benilla` names.
A real-scroll bar overrides the template's OnValueChanged on its instance, which wins
because the loader runs every `<Scripts>` block in order.
-->
<Script><![CDATA[
-- 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.
-- One valueStep per step, where the reference's arrows and wheel move half the bar
-- (UIPanelTemplates.xml:137/149, UIPanelTemplates.lua:150-157).
-- Soundless: the reference plays UChatScrollButton on an arrow click, never on the wheel.
function BenillaScrollBar_Step(scrollBar, dir)
local step = scrollBar:GetValueStep()
if step <= 0 then step = 1 end
@@ -34,11 +26,10 @@
if val >= maxV then down:Disable() else down:Enable() end
end
-- 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).
-- The value is pixels in steps of a row; the parent holds the 0-based top row in
-- `frame.offset` and its repaint in `frame.updateFunc`, which runs only when that row
-- changes, so a repaint that re-seats the thumb ends the loop. A ScrollFrame parent also
-- gets SetVerticalScroll, as UIPanelTemplates.xml:156-158 routes it.
function BenillaFauxScrollBar_OnValueChanged(scrollBar, value)
local frame = scrollBar:GetParent()
local step = scrollBar:GetValueStep()
@@ -52,10 +43,8 @@
if frame.SetVerticalScroll then frame:SetVerticalScroll(value) end
end
-- 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.
-- ReputationFrame's hang, 8 left, 21 above, 20 below (ReputationFrame.xml:573-599 against
-- UIPanelTemplates.xml:166-181). Lazy: the bar does not exist at the trough's OnLoad.
function BenillaScrollTrough_Seat(trough, scrollBar)
if trough.seated then return end
trough.seated = true
@@ -64,9 +53,7 @@
trough:SetPoint("BOTTOMRIGHT", scrollBar, "BOTTOMRIGHT", 7, -20)
end
-- 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.
-- Show and hide a bar only through here, so its `<barname>Trough` follows.
function BenillaScrollBar_SetShown(scrollBar, shown)
local trough = getglobal(scrollBar:GetName() .. "Trough")
if trough then BenillaScrollTrough_Seat(trough, scrollBar) end
@@ -79,9 +66,7 @@
end
end
-- 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.
-- For a stock `FauxScrollFrameTemplate` frame; call after `FauxScrollFrame_Update`.
function BenillaScrollTrough_Follow(scrollFrameName)
local sf = getglobal(scrollFrameName)
if not sf then return end
@@ -93,30 +78,25 @@
if sf:IsShown() then trough:Show() else trough:Hide() end
end
-- 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).
-- One row per wheel notch (+1 up), from the owner window's OnMouseWheel; the reference's
-- `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 ══════════════════════════════════════════
-- 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.
-- Shaped on UIPanelTemplates.xml:166-211: the bar is the `Slider` child `$parentBar`.
BENILLA_SCROLL_STEP = 20 -- px per arrow click or wheel notch
BENILLA_SCROLL_PAD = 20 -- px of room below the child's last element
-- 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.
-- The loop back through SyncBar ends in one hop: Slider:SetValue ignores an equal value.
function BenillaScrollBar_OnValueChanged(bar, value)
bar:GetParent():SetVerticalScroll(value)
BenillaScrollBar_UpdateArrows(bar)
end
-- 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.
-- Hides the bar when the content fits (reference: ScrollFrame_OnScrollRangeChanged).
function BenillaScroll_SyncBar(sf)
local bar = getglobal(sf:GetName() .. "Bar")
if not bar then return end
@@ -128,8 +108,7 @@
BenillaScrollBar_SetShown(bar, range > 0)
end
-- 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.
-- Clamped here: SetVerticalScroll stores any value, as the reference's does.
function BenillaScroll_Step(sf, delta)
local target = sf:GetVerticalScroll() + delta
local range = sf:GetVerticalScrollRange()
@@ -144,7 +123,7 @@
BenillaScroll_Step(sf, -delta * BENILLA_SCROLL_STEP)
end
-- A widget's resolved bottom, or nil if hidden, a blank FontString, or not laid out yet.
-- 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
@@ -156,13 +135,11 @@
return w:GetBottom()
end
-- 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.
-- Rects resolve after Lua runs, so owners re-run this from OnUpdate; it writes only on a
-- real change, since SetHeight invalidates layout.
function BenillaScroll_ResizeChild(scrollName, widgets)
local sf = getglobal(scrollName)
-- The frame's own child, not a derived name: the reference pairs `<X>ScrollFrame` with
-- `<X>ScrollChildFrame`.
-- 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()
@@ -182,11 +159,7 @@
end
]]></Script>
<!--
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`).
-->
<!-- The stock scrollbar art cropped to its centred 16px, as `UIPanelScrollBarButton` does. -->
<Slider name="BenillaScrollBarTemplate" virtual="true">
<Size><AbsDimension x="16" y="0"/></Size>
<Frames>
@@ -219,24 +192,19 @@
</Slider>
<!--
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.
A bar's trough, drawn per window in 1.12. Declare it before its bar, named
`<barname>Trough`, with no anchors. `UI-Character-ScrollBar` holds two 31-wide pieces,
the left capped at the top, the right at the bottom.
Deviation: a three-slice where the reference overlaps two quads, since two quads reach
only 362 units and a page here is 563 tall.
The fill is ItemTextFrame's 0.8-alpha black; fill and art are in separate layers because
the client orders overlapping quads within one layer by texture handle.
-->
<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, inset 6 from each edge. -->
<!-- The channel interior, inset 6 from each edge of the 31-wide piece. -->
<Texture name="$parentFill">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor>
@@ -246,7 +214,7 @@
</Texture>
</Layer>
<Layer level="BORDER">
<!-- Cap + up-arrow socket + crossbar: left column, rows 0..22 (v 0 .. 23/256). -->
<!-- Left column, rows 0..22: the cap, the up-arrow socket and a crossbar. -->
<Texture name="$parentTop" file="Interface\PaperDollInfoFrame\UI-Character-ScrollBar">
<Size><AbsDimension y="23"/></Size>
<Anchors>
@@ -255,8 +223,7 @@
</Anchors>
<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), ending on v 0.421875 as ReputationFrame's does. -->
<!-- Right column, ending on v 0.421875 as ReputationFrame's does. -->
<Texture name="$parentBottom" file="Interface\PaperDollInfoFrame\UI-Character-ScrollBar">
<Size><AbsDimension y="26"/></Size>
<Anchors>
@@ -265,8 +232,7 @@
</Anchors>
<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: an anchor to a later sibling falls back to the parent. -->
<!-- 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"/>
@@ -1,13 +1,9 @@
<Ui>
<!--
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.
Deviation: the pet page's autocast widgets take the pet bar button's proportions, so the
shine and the corner brackets sit on the button's edge and share its centre. Stock
SpellBookFrame.xml has the shine 36x36 at CENTER (1,1), scale 1.22, the brackets 60; 71.53
is 37 x 58/30 (the art fills 33/64 of UI-AutoCastableOverlay.blp).
-->
<Script><![CDATA[
for i = 1, 12 do
+36 -56
View File
@@ -1,35 +1,30 @@
# The built-in interface, parsed like any addon's `.toc`. Entries load in order: a bare filename
# is ours, from `assets/ui`; a path is the reference's own file, off the player's patch chain.
# The list loads on entering the world, then third-party addons. Deviation: entry 0, Fonts.xml,
# loads alone at Startup, because the glue screens share its glyph atlas.
# A frame built at load resolves `inherits=`, `parent=` and a named `relativeTo=` against names
# declared above; a missing one only warns (the template is skipped, the parent falls back to the
# enclosing frame).
# On a collision a later Lua global or template replaces the earlier one, but a named frame
# publishes its global only while the name is free.
# A stock `.xml` that does not `<Script file=>` its own `.lua` needs both rows, `.lua` first
# (`every_chain_xml_brings_its_own_lua`).
# Blizzard's LoadOnDemand addons have no row, as in the reference: `ui_script::addons` loads each
# off the chain on first use, after every template here is registered.
# The built-in interface, in load order: a bare filename is ours, from `assets/ui`; a path is
# the reference's own file, off the player's patch chain. Entry 0, Fonts.xml, loads alone into
# every boot VM, from Startup on; the rest loads on entering the world, then third-party addons.
# The font objects thus exist before the login screen; the reference loads Fonts.xml with the
# rest of FrameXML.
# `inherits=`, `parent=` and a named `relativeTo=` resolve only against names declared above; a
# missing one only warns (the template is skipped, the parent falls back to the enclosing frame).
# A later Lua global or template replaces an earlier one; a named frame publishes its global
# only while the name is free.
# A stock `.xml` that does not `<Script file=>` its own `.lua` needs both rows, `.lua` first.
# LoadOnDemand addons have no row, as in the reference; they load off the chain on first use.
## Interface: 11200
## Title: benilla
Interface\FrameXML\Fonts.xml
# Second, not first as in the reference, because entry 0 must be Fonts.xml. The VM already ran it
# at Startup; this pass makes the manifest complete, and stock files read the strings at load.
# Second, not first as in the reference, because entry 0 must be Fonts.xml.
Interface\FrameXML\GlobalStrings.lua
# `Localize()` runs at load and overwrites GlobalStrings keys, so this follows them.
Interface\FrameXML\Localization.xml
# `GetText` and its gender and plural helpers: a Lua global, not an engine binding (the client's
# `FrameScript_GetText` at `0x703bf0` calls it). UIParent.xml sources this file again.
# `GetText` is a Lua global, not an engine binding: the client's `0x703bf0` calls it.
Interface\FrameXML\LocaleProperties.lua
Interface\FrameXML\BasicControls.xml
# Before UIParent.xml, whose `LowHealthFrame` has `parent="WorldFrame"`.
Interface\FrameXML\WorldFrame.xml
# Above every file that names `parent="UIParent"` or adds a `UIPanelWindows` row at load.
# `ui_script::manifest` reruns `UIParent_ManageFramePositions` once the whole list has loaded.
Interface\FrameXML\UIParent.xml
Interface\FrameXML\ItemButtonTemplate.xml
# Before GameTooltip.xml and StaticPopup.xml, whose money rows inherit `SmallMoneyFrameTemplate`.
@@ -40,8 +35,7 @@ Interface\FrameXML\MoneyInputFrame.xml
Interface\FrameXML\GameTooltip.xml
Interface\FrameXML\UIDropDownMenu.xml
Interface\FrameXML\UIMenu.xml
# Before the unit frames: their dropdowns' OnLoad runs `UIDropDownMenu_Initialize`, whose
# initializer calls `UnitPopup_ShowMenu` at once.
# Before the unit frames, whose dropdowns' OnLoad reaches `UnitPopup_ShowMenu`.
Interface\FrameXML\UnitPopup.xml
Interface\FrameXML\ItemRef.xml
# The `TextStatusBar` template and verbs, which the unit bars and `MainMenuExpBar` inherit.
@@ -49,8 +43,7 @@ Interface\FrameXML\TextStatusBar.lua
Interface\FrameXML\TextStatusBar.xml
# Before PartyFrame.xml, whose rows reach `RefreshBuffs` from their OnLoad.
Interface\FrameXML\BuffFrame.xml
# Two function-only kits: the unit windows below call `UnitFrame_Initialize` from their OnLoad,
# and the player and pet frames `CombatFeedback_Initialize`.
# Function-only kits the unit windows below call from their OnLoad.
Interface\FrameXML\UnitFrame.xml
Interface\FrameXML\CombatFeedback.xml
Interface\FrameXML\PlayerFrame.xml
@@ -59,43 +52,38 @@ Interface\FrameXML\TargetFrame.xml
Interface\FrameXML\PetFrame.xml
# Anchors `relativeTo="TargetFrame"`.
Interface\FrameXML\ComboFrame.xml
# Ours: benilla's scroll kits, for our options window and script log.
# Ours: the scroll parts of our options window and script log.
ScrollTemplates.xml
Interface\FrameXML\UIPanelTemplates.lua
Interface\FrameXML\UIPanelTemplates.xml
# The reference's three options windows, loaded hidden so addons find their frames by name;
# nothing of ours may show them. Deviation: the player opens benilla's own options window,
# `OptionsFrame.xml` below. The video window's templates (OptionsFrameTemplates.xml) inherit
# UIPanelTemplates.xml's, and its dropdowns run `UIDropDownMenu_Initialize` at load. It holds the
# global `OptionsFrame`, so ours is `BenillaOptionsFrame`.
# The reference's three options windows, loaded hidden so addons find their frames by name.
# Deviation: the player opens our own options window, `OptionsFrame.xml` below, because 1.12's
# settings screens are much worse to use; the stock one holds `OptionsFrame`.
Interface\FrameXML\OptionsFrame.xml
Interface\FrameXML\SoundOptionsFrame.xml
# Inherits `OptionsCheckButtonTemplate`; brings CharacterFrameTemplates.xml and declares
# `UIOptionsFrameCheckButtons`, which MultiActionBars.lua writes at load.
# Inherits `OptionsCheckButtonTemplate`, and declares `UIOptionsFrameCheckButtons`, which
# MultiActionBars.lua writes at load.
Interface\FrameXML\UIOptionsFrame.xml
# StaticPopup.lua starts `StaticPopupDialogs` empty: a file that adds a dialog at load follows it.
Interface\FrameXML\StaticPopup.xml
Interface\FrameXML\CoinPickupFrame.xml
# Ours: the options window's Keybindings page. Before OptionsFrame.xml, whose Keybindings page
# inherits these templates and calls this script at OnLoad.
# Ours: the Keybindings page's templates and script, which OptionsFrame.xml uses at load.
KeyBindingsPage.xml
# Ours: `BenillaOptionsFrame`, the options window the game menu opens. Its rows read
# `OptionsFrameSliders` and the option globals the stock windows above set at load.
# Ours: `BenillaOptionsFrame`; its rows read the option globals the stock windows above set.
OptionsFrame.xml
# Declares `MainMenuBar`, `MainMenuBarArtFrame` and `MainMenuExpBar`, which later files use as a
# parent or read at load.
# `MainMenuBar`, `MainMenuBarArtFrame` and `MainMenuExpBar`, which later files read at load.
Interface\FrameXML\MainMenuBar.xml
Interface\FrameXML\Cooldown.xml
# `ActionButtonTemplate`, which ActionBarFrame.xml's `ActionBarButtonTemplate` inherits.
Interface\FrameXML\ActionButtonTemplate.xml
Interface\FrameXML\ActionBarFrame.xml
# The four optional bars (action slots 25-72), built on `ActionBarButtonTemplate`.
# The four optional bars, action slots 25-72.
Interface\FrameXML\MultiActionBars.xml
Interface\FrameXML\PetActionBarFrame.xml
# `UpdateMicroButtons` reads CharacterFrame, SpellBookFrame, QuestLogFrame, GameMenuFrame, the
@@ -119,17 +107,15 @@ Interface\FrameXML\UIErrorsFrame.xml
# Anchors `relativeTo="UIErrorsFrame"`.
Interface\FrameXML\RaidWarning.xml
Interface\FrameXML\ContainerFrame.xml
# `CharacterFrame` is the parent of every tab page below. PaperDollFrame.xml declares the stat
# templates PetPaperDollFrame.xml inherits and `PaperDollItemSlotButtonTemplate`, which the bag
# bar inherits. `CharacterFrame_OnLoad` reads `PlayerFrameHealthBar`, `PlayerFrameManaBar` and
# `MainMenuExpBar` and calls `PanelTemplates_SetNumTabs`, all declared above.
# `CharacterFrame` parents every tab page below, and its OnLoad reads `PlayerFrameHealthBar`,
# `PlayerFrameManaBar` and `MainMenuExpBar`; PaperDollFrame.xml declares templates the pet page
# and the bag bar inherit.
Interface\FrameXML\CharacterFrame.xml
Interface\FrameXML\PaperDollFrame.xml
Interface\FrameXML\PetPaperDollFrame.xml
Interface\FrameXML\MainMenuBarBagButtons.xml
Interface\FrameXML\StackSplitFrame.xml
# Ours. Deviation: the keyring is an ordinary container (id -2), so its tooltip uses `SetBagItem`;
# this wraps `ContainerFrameItemButton_OnEnter`, capturing the stock body at load.
# Ours: wraps the stock bag verbs; the keyring is a container (id -2).
ContainerFrameAdapters.xml
Interface\FrameXML\ReputationFrame.xml
Interface\FrameXML\SkillFrame.xml
@@ -137,8 +123,7 @@ Interface\FrameXML\SkillFrame.xml
Interface\FrameXML\HonorFrame.xml
Interface\FrameXML\DressUpFrame.xml
Interface\FrameXML\SpellBookFrame.xml
# Ours. Deviation: the spellbook's autocast brackets (71.53, reference 60) and shine (37x37 at
# scale 1.48) take the pet action button's proportions. Resizes `SpellButton1..12` at load.
# Ours: the pet page's autocast widgets in the pet action button's proportions.
SpellBookAdapters.xml
# RaidFrame.xml names `parent="FriendsFrame"` and MailFrame.xml inherits `FriendsFrameTabTemplate`.
Interface\FrameXML\FriendsFrame.xml
@@ -163,25 +148,20 @@ Interface\FrameXML\QuestTimerFrame.xml
Interface\FrameXML\ChatFrame.xml
Interface\FrameXML\FloatingChatFrame.xml
# `ShapeshiftBar_OnLoad` can show the bar, whose OnShow runs `UIParent_ManageFramePositions`. The
# pass reads the reputation watch bar, the quest timer and watch frames, the chat frames and more
# unguarded, so this follows all of them (enforced by
# `every_load_time_runner_of_the_managed_pass_follows_what_it_reads`).
# `ShapeshiftBar_OnLoad` can reach `UIParent_ManageFramePositions`, which reads the frames above
# unguarded.
Interface\FrameXML\BonusActionBarFrame.xml
# `WorldStateAlwaysUpFrame`'s OnLoad runs the same pass, which also reads
# `ShapeshiftBarLeft/Middle/Right`, so this follows BonusActionBarFrame.xml.
Interface\FrameXML\WorldStateFrame.xml
# `ClassTrainerListScrollFrameTemplate`, which the on-demand macro, trainer, trade skill and craft
# addons inherit.
# `ClassTrainerListScrollFrameTemplate`, which on-demand addons inherit.
Interface\FrameXML\ClassTrainerFrameTemplates.xml
# Ours: the `/errors` script error log, which 1.12 lacks, and benilla's slash commands, added to
# `SlashCmdList` at load.
# Ours: the `/errors` script error log and our slash commands.
ScriptLogFrame.xml
Interface\FrameXML\HelpFrame.xml
Interface\FrameXML\CinematicFrame.xml
Interface\FrameXML\TabardFrame.xml
Interface\FrameXML\TutorialFrame.xml
# Ours: the ESC game menu. Deviation: its ladder is the 1.15 Era client's. Last: it wraps
# `ToggleGameMenu`, `IsOptionFrameOpen` and `UpdateMicroButtons`, captured at load, to count
# `BenillaOptionsFrame`.
# Ours: the ESC game menu. Last: it wraps `ToggleGameMenu`, `IsOptionFrameOpen` and
# `UpdateMicroButtons` to count `BenillaOptionsFrame`.
GameMenuFrame.xml
+108 -298
View File
@@ -1,41 +1,22 @@
//! `addon_harness` — load a folder of addons, one per VM, and print what happened.
//! `addon_harness`: load a folder of addons, one per VM, and print what happened.
//!
//! ```text
//! cargo run -q -p benilla-app --example addon_harness -- <folder> [--verbose] [--why <substr>] [--deep [n]] [--status <file>] [--diff <file>]
//! or: ... -- <folder> --probe <Name> [--eval <lua> | --mouse <x>,<y> | --tick <secs>]... (one addon, then
//! ask its VM — the steps run in the order given, so a read can be taken with the cursor
//! parked somewhere the addon cares about)
//! cargo run -q -p benilla-app --example addon_harness -- <folder> [--verbose] [--why <substr>]
//! [--deep [n]] [--status <file>] [--diff <file>]
//! or: ... -- <folder> --probe <Name> [--eval <lua> | --mouse <x>,<y> | --tick <secs>]...
//! (one addon, then ask its VM; the steps run in the order given)
//! ```
//!
//! The instrument decision 1188 phase 6 asks for: *"which addons work" is a number that can be
//! re-read on any day*. The mechanics, and what the numbers are and are not worth, are in
//! [`benilla_app::addon_harness`]'s module doc — read it before quoting anything from here.
//!
//! **Expect a long tail and do not chase 100 %** (1188's own instruction). The report is a
//! distribution, not a pass/fail: a handful of addons will always want features we have not built.
//! Measures which addons work, as a number that can be re-read any day; what the numbers are
//! worth is in [`benilla_app::addon_harness`]'s module doc. Expect a long tail: the report is a
//! distribution, not a pass/fail.
use benilla_app::addon_harness;
/// How much traceback `--why` prints per addon. Two lines gave the message and mlua's first
/// frame, which was enough to RANK a row and never enough to FIND one: chasing a `SetTexture`
/// failure to its call site needed the frames below it, and the addon/file/line only appear there.
/// How much traceback `--why` prints per addon: the addon, file and line sit below the first frame.
const WHY_TRACEBACK_LINES: usize = 8;
/// Print a ranked demand table — and **say what was dropped**.
///
/// Every one of these lists is a queue, and each was printed as a silent top-N. A silent cut reads
/// as "that is the whole list", and this arc has one expensive instance of exactly that: the
/// "a reference frame we do not build" class was swept, found clean, and recorded CLOSED — while
/// `ShapeshiftBarLeft` sat below the cut with two addons behind it, and stayed there until an
/// addon's first error named it (1219's class, first regions). The sweep was honest; the list it
/// swept was not complete and did not say so.
///
/// So the tail is now stated: how many rows, and how many addon-mentions, were not shown. The
/// caller keeps its own `take` — this only refuses to hide the remainder.
///
/// `--deep [n]` overrides every caller's `take` (see [`DEEP`]). 1242 made the cut VISIBLE and
/// rejected raising it — an unreadable report helps nobody. But "visible" only tells a sweep that
/// rows exist; it still cannot read them, and `--why` opens one name at a time, which is no way to
/// walk 1930 of them. So the cut stays where it is for the report, and a sweep asks for the rest.
/// Print a ranked table, stating how many rows and addon-mentions fell below the cut; `--deep [n]`
/// overrides every caller's `take` (see [`DEEP`]).
fn ranked(rows: Vec<(String, usize)>, take: usize) {
let take = DEEP.get().copied().flatten().unwrap_or(take);
let total = rows.len();
@@ -52,10 +33,8 @@ fn ranked(rows: Vec<(String, usize)>, take: usize) {
}
}
/// One of the probe's two error lists, printed with its count — and printed even when EMPTY.
///
/// A silent absence and a list nobody asked for read identically, and the whole point of a probe
/// run is to tell "this addon loaded clean and died in a handler" from "it never loaded at all".
/// One of the probe's two error lists with its count, printed even when empty, so "loaded clean
/// and died in a handler" reads apart from "never loaded".
fn report_lines(label: &str, lines: &[String]) {
println!(" {label}: {}", lines.len());
for line in lines {
@@ -65,18 +44,14 @@ fn report_lines(label: &str, lines: &[String]) {
}
}
/// The `--deep` override, set once in `main`. `Some(n)` shows `n` rows of EVERY ranked list;
/// `--deep` with no number means all of them. Absent, each list keeps the `take` its caller chose.
/// The `--deep` override, set once in `main`: `Some(n)` shows `n` rows of every ranked list,
/// a bare `--deep` all of them; absent, each list keeps its caller's `take`.
static DEEP: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
/// The row's frame names, bounded for the line and **saying so when it bounds**.
///
/// `RenderReport::frames` carries every named frame now; the cap that used to live in the
/// collection (and silently evicted the very names a test asserted on) lives here instead.
/// The row's frame names, bounded for the line and saying so when it bounds.
fn render_frames(frames: &[String]) -> String {
use benilla_app::addon_harness::render::MAX_NAMED_FRAMES;
// `--deep` opens THIS bound too. It was added for the ranked tables, but the principle is one
// principle: a bounded view is fine, a bounded view you cannot open is not.
// `--deep` opens this bound too.
let cap = DEEP.get().copied().flatten().unwrap_or(MAX_NAMED_FRAMES);
if frames.len() <= cap {
return frames.join(",");
@@ -84,19 +59,10 @@ fn render_frames(frames: &[String]) -> String {
format!("{},+{} more", frames[..cap].join(","), frames.len() - cap)
}
/// Our `_G` against the captured 1.12 `_G` — decision 1189's diff, re-runnable.
///
/// 1189 did this once, by hand, and its finding was that **a superset is not free**: 1.12 addons
/// branch on presence (`if SomeName then`), so a name we publish that the reference does not have
/// can route an addon down a path the real client never takes. That makes BOTH directions of this
/// 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`, 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
/// opened.
/// Our `_G` against the captured 1.12 `_G` (`reference/1.12-globals.tsv`). Both directions
/// matter: 1.12 addons feature-test with `if SomeName then`, so a name we publish that the
/// reference lacks can send an addon down a path the real client never takes. `lod` rows are the
/// twelve LoadOnDemand `Blizzard_*` addons' names, counted apart as a live capture misses them.
fn surface_report(deep: bool, dump_path: Option<String>) {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
@@ -120,10 +86,7 @@ fn surface_report(deep: bool, dump_path: Option<String>) {
let ours: std::collections::BTreeMap<String, String> =
addon_harness::surface().into_iter().collect();
// `--surface-dump <file>` writes our raw `_G` as `name<TAB>type`, the same shape the reference
// TSV has, so the two can be joined by anything. The printed report answers the questions I
// thought to ask; a sweep two sessions from now will have different ones, and re-deriving our
// side by scraping a human-readable report is exactly how a measurement quietly goes wrong.
// `--surface-dump <file>` writes our raw `_G` as `name<TAB>type`, the reference TSV's shape.
if let Some(path) = dump_path {
let body: String = ours
.iter()
@@ -136,13 +99,12 @@ fn surface_report(deep: bool, dump_path: Option<String>) {
}
}
println!("\n SURFACE DIFF — our _G vs the captured 1.12 _G (decision 1189)");
println!("\n SURFACE DIFF — our _G vs the captured 1.12 _G");
println!(" FrameXML digest : {}", addon_harness::framexml_digest());
println!(" reference names : {}", reference.len());
println!(" ours : {}", ours.len());
// Absent from us, by the reference's own type — a function we lack is a different problem from
// a string we lack, and `lod` is a third thing again.
// Absent from us, by the reference's own type.
let mut missing_by_kind: std::collections::BTreeMap<&str, Vec<&str>> = Default::default();
for (name, kind) in &reference {
if !ours.contains_key(name) {
@@ -157,15 +119,8 @@ fn surface_report(deep: bool, dump_path: Option<String>) {
println!(" {:<10} {}", kind, names.len());
}
// The direction 1189 cared about and nothing else here can see.
//
// **Split by OUR type, because the total on its own misleads.** Most of these are `table` — the
// frame and region names our own FrameXML publishes, which differ from Blizzard's simply
// because our XML is our own reimplementation and names its pieces itself. That is not the
// hazard 1189 described. The hazard is a **function**: `if SomeApiName then` is how a 1.12
// addon feature-tests, and a verb we publish that the client never had can route it down a path
// the real client never takes. So the function row is the one to read first, and the one a
// future landing should be able to drive to zero.
// Published by us and absent from 1.12, split by our type: most are `table`, our FrameXML's
// own frame names; a `function` is the hazard, since addons feature-test on it.
let extra: Vec<(&str, &str)> = ours
.iter()
.filter(|(n, _)| !reference.contains_key(n.as_str()))
@@ -176,7 +131,7 @@ fn surface_report(deep: bool, dump_path: Option<String>) {
extra_by_kind.entry(kind).or_default().push(name);
}
println!(
"\n PUBLISHED BY US, ABSENT FROM 1.12 ({}) — a superset is not free (1189):",
"\n PUBLISHED BY US, ABSENT FROM 1.12 ({}) — a superset is not free:",
extra.len()
);
for (kind, names) in &extra_by_kind {
@@ -187,12 +142,8 @@ fn surface_report(deep: bool, dump_path: Option<String>) {
};
println!(" {:<10} {}{}", kind, names.len(), note);
}
// Functions in full even without --deep: it is the row that matters and it has to be readable.
//
// Split again on the `Benilla` prefix, which is the difference between a name that CAN collide
// with an addon's expectations and one that cannot. Nothing in the 1.12 corpus feature-tests
// `BenillaPaperDollSlot_OnClick`; our own namespace is safe by construction, and leaving 443 of those
// in the list would bury the ones that are not.
// Functions in full even without --deep, split on the `Benilla` prefix: no 1.12 addon
// feature-tests our own namespace.
if let Some(fns) = extra_by_kind.get("function") {
let (ours_ns, unprefixed): (Vec<&&str>, Vec<&&str>) = fns
.iter()
@@ -209,7 +160,7 @@ fn surface_report(deep: bool, dump_path: Option<String>) {
println!(
" ^ read these: an unprefixed verb 1.12 lacks is what `if SomeName then` finds.\n \
Most are our own UI's helpers (KeyBindings_*, Options*), which are only\n \
a naming question — but a POST-1.12 API name here is 1189's hazard exactly, because an\n \
a naming question — but a POST-1.12 API name here is the hazard, because an\n \
addon that feature-tests it takes a branch written for a client we are not."
);
}
@@ -246,8 +197,7 @@ fn main() {
};
let rest: Vec<String> = args.collect();
// `--surface` — decision 1189's comparison, made re-runnable. It needs no corpus, so it is
// handled before the root is used for anything.
// `--surface` needs no corpus, so it is handled before the root is used.
if root == "--surface" || rest.iter().any(|a| a == "--surface") {
let dump_path = rest
.iter()
@@ -259,34 +209,15 @@ fn main() {
}
let verbose = rest.iter().any(|a| a == "--verbose");
// `--why <substring>` — the addons behind one row, with their verbatim errors. The
// ranked table collapses quoted names by design (1193); this is the read-back, and two of this
// arc's decisions came from doing it by hand (1206, 1210). It matches the normalised row AND
// the raw text, so `--why <a row>` and `--why <a name>` both work — the second did not, and
// that is what made the whole widget-method class unreadable here.
//
// It reads back through the **method demand tables** too, which nothing did: every ranking here
// is built by scanning, three of them have opened with fiction (1210, 1218, 1227), and the rule
// that came out of that is to open the corpus line before quoting a row. Verifying the per-kind
// table's own first head cost a temporary debug print and a second corpus run for want of this.
// `--why <substring>`: the addons behind one row, with their verbatim errors. Matches the
// normalised row and the raw text, and reads back through the method demand tables too.
let why = rest
.iter()
.position(|a| a == "--why")
.and_then(|i| rest.get(i + 1))
.cloned();
// `--status` writes the per-addon ok/fail roster; `--diff <file>` compares against one.
//
// **The instrument this survey was missing, and the reason it is missing is instructive.**
// Every column here is a TOTAL, so a landing that fixes one addon and breaks another reads as
// a clean zero — indistinguishable from a change that did nothing. That happened: the shadow
// accessors (`7824c154`) gained FuBar_NavigatorFu and held the column at 107, so something was
// lost, and there was no way to ask what. Every "zero delta" recorded in this arc before now
// could have been hiding the same swap.
// `--status <file>` WRITES the roster, rather than printing it. It printed at first, and the
// obvious `--status > roster.txt` then captured the whole report — 53 report lines parsed as
// addon rows, because `--diff` splits on the last space and almost anything satisfies that. An
// instrument whose output needs a `grep` incantation to be usable is one that will be used
// wrong; the file is the artefact, so the tool writes the file.
// `--status <file>` writes the per-addon ok/fail roster to a file; `--diff <file>` compares
// against one, since a total hides a fix and a break that cancel out.
let status = rest
.iter()
.position(|a| a == "--status")
@@ -306,10 +237,8 @@ fn main() {
let _ = DEEP.set(deep);
let root = std::path::PathBuf::from(root);
// `--together` — the whole folder in ONE VM, the control for the survey's one-VM-per-addon
// bound. Not a column and not a headline (see `addon_harness::together`'s header for what it
// cannot answer); `--diff <a survey roster>` names the rows the bound is costing, which is
// the question it exists for.
// `--together`: the whole folder in one VM, the control for the survey's one-VM-per-addon
// bound; `--diff <a survey roster>` names the rows the bound costs.
if rest.iter().any(|a| a == "--together") {
let rows = addon_harness::together::survey_together(&root);
if rows.is_empty() {
@@ -335,9 +264,8 @@ fn main() {
rows.len(),
rows.len() - raised.len() - wobbly.len()
);
// Named, never folded into either count: Lua hashes a table key by its pointer, so a
// registry keyed by objects walks in a different order every process (see the module
// doc). A row that raises in some runs and not others is that showing through.
// Named apart: Lua hashes a table key by its pointer, so a registry keyed by objects walks
// in a different order every process.
println!(
" ORDER-SENSITIVE — raised in some runs, not all ({}):",
wobbly.len()
@@ -352,8 +280,7 @@ fn main() {
);
}
if let Some(path) = &diff {
// The survey's roster, read back: `ok` there and clean here is agreement; `fail`
// there and clean here is the bound, priced.
// `fail` in the survey and clean here is the bound, priced.
let prior: std::collections::BTreeMap<String, bool> = std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
@@ -363,8 +290,7 @@ fn main() {
let mut freed: Vec<&str> = Vec::new();
let mut only_together: Vec<&str> = Vec::new();
for r in &rows {
// Only the rows that are the same in every run are compared — an order-sensitive
// one belongs to neither list, which is the whole point of naming it separately.
// Only rows the same in every run are compared.
match (prior.get(&r.name), r.raised_in) {
(Some(false), 0) => freed.push(&r.name),
(Some(true), n) if n == r.runs => only_together.push(&r.name),
@@ -396,19 +322,14 @@ fn main() {
return;
}
// `--probe <Name> [--eval <lua> ...]` — ONE addon, loaded the way the survey loads it, then
// asked. Handled before the survey because it is not one: it prints no column and it is not a
// measurement (an eval can mutate the VM), so mixing the two outputs would invite a probe
// number into a record. See `addon_harness::probe`'s header for what it is worth and where it
// deliberately stops — session start, before the render and use probes touch anything.
// `--probe <Name> [--eval <lua> ...]`: one addon, loaded as the survey loads it, then asked.
// Not a measurement (an eval can mutate the VM), so it prints no column.
if let Some(name) = rest
.iter()
.position(|a| a == "--probe")
.and_then(|i| rest.get(i + 1))
{
// `--eval <lua>` and `--mouse <x>,<y>` are ONE ordered list, not two: a read taken
// before the cursor arrived and one taken after answer different questions, and which is
// which is the order the caller typed.
// `--eval`, `--tick` and `--mouse` are one ordered list, run in the order typed.
let steps: Vec<addon_harness::probe::Step> = rest
.iter()
.enumerate()
@@ -467,9 +388,7 @@ fn main() {
.count();
println!("\n{} addon(s) under {}", reports.len(), root.display());
// Which VM the survey ran against. Without an install there is no GlobalStrings.lua, ~5,000
// globals are missing, and every number below is worse for a reason that has nothing to do
// with the client — say so rather than letting two machines' numbers be compared in silence.
// Without an install there is no GlobalStrings.lua and about 5,000 globals are missing.
println!(
" VM: the stock 1.12 FrameXML off the player's chain + a seated session{}\n",
if addon_harness::seated_with_global_strings() {
@@ -478,10 +397,7 @@ fn main() {
" ** no install found: GlobalStrings absent, these numbers are NOT comparable **"
}
);
// The tree these numbers came from. Two runs are comparable only if this matches: in a dev
// build `assets/ui` is read from the SOURCE TREE, so anything else editing the checkout moves
// the headline with no rebuild. Quoting a delta across two different digests is how a wrong
// attribution got into a decision record (1209).
// Two runs compare only at the same digest: a dev build reads `assets/ui` from the source tree.
println!(
" FrameXML digest : {}",
addon_harness::framexml_digest()
@@ -490,15 +406,12 @@ fn main() {
" loaded without a single load error : {loaded}/{}",
reports.len()
);
// **The reconciliation line, printed always** (decision 2155). `loaded` counts what RAISED;
// a manifest entry naming a file the package does not contain is not that — the reference logs
// `Couldn't open %s` and carries on — and it used to be counted here. Every past record's
// figure was the stricter one, so the stricter one is printed beside the honest one rather
// than left for a reader to reconstruct: two numbers cannot be silently confused, one can.
// `loaded` counts what raised; a manifest entry naming a missing file is not that (the
// reference logs `Couldn't open %s` and carries on), so the stricter count prints beside it.
let strict = reports.iter().filter(|r| r.errors.is_empty()).count();
println!(
" (…{} of those name a file their own package does not contain, which the reference \
logs and carries on from; the pre-2155 column counted those as failures: {strict}/{})",
logs and carries on from; the strict column counts those as failures: {strict}/{})",
loaded - strict,
reports.len()
);
@@ -507,11 +420,8 @@ fn main() {
reports.len()
);
println!(" with a dependency not installed : {blocked}");
// The stricter column, and the one that answers what the survey is really asking. Every other
// number here is LOAD-time; this one drives the client's own session start
// (ADDON_LOADED -> VARIABLES_LOADED -> PLAYER_LOGIN -> PLAYER_ENTERING_WORLD, then a second of
// ticks) and reports what the addon's HANDLERS raised. Four decision records in this arc end
// with "the headline cannot see this"; this is the number that can.
// The session column: drives ADDON_LOADED -> VARIABLES_LOADED -> PLAYER_LOGIN ->
// PLAYER_ENTERING_WORLD and a second of ticks, and reports what the handlers raised.
let survived = reports
.iter()
.filter(|r| r.loaded && r.session_errors.is_empty())
@@ -520,9 +430,7 @@ fn main() {
" ...and survived a session start : {survived}/{}",
reports.len()
);
// The UI-probe column: of those, how many survive having their OVERRIDES actually invoked.
// The director found this blind spot by playing the game — an addon that replaces
// `ToggleBackpack` and is never called looked identical to one that works.
// The UI-probe column: of those, how many survive having their overrides invoked.
let probed = reports
.iter()
.filter(|r| r.loaded && r.session_errors.is_empty() && r.probe_errors.is_empty())
@@ -531,10 +439,8 @@ fn main() {
" ...and survived a UI probe : {probed}/{}",
reports.len()
);
// **The render column** — the only one here that asks whether anything was DRAWN. Every number
// above it asks whether something raised, and the director's Bagnon report raised nothing at
// all: a window with a title, a gold line and no bag slots, scoring a clean pass on all four.
// `addon_harness::render`'s header is the design and its honest bounds.
// The render column, the only one that asks whether anything was drawn; see
// `addon_harness::render`'s header.
let drew = reports
.iter()
.filter(|r| r.render.drew() != addon_harness::Drew::Nothing)
@@ -554,9 +460,7 @@ fn main() {
.count(),
);
println!(" of those: {own} drew a window of their own, {overlay} painted onto ours");
// The actionable list, and the reason this column exists: an addon that loads clean, survives a
// session start AND a UI probe, and still puts nothing on screen. Nothing else here can name
// one. Bagnon was on this list.
// Addons that load, survive a session start and a UI probe, and still draw nothing.
let silent: Vec<&str> = reports
.iter()
.filter(|r| {
@@ -568,23 +472,8 @@ fn main() {
.map(|r| r.name.as_str())
.collect();
if !silent.is_empty() {
// **This list is a QUESTION, not a defect list, and the header used to say otherwise.**
//
// It read "read this list first", which invites treating every row as a bug. Most are not.
// The survey seats a player and an empty world: no buffs, no target, no combat, no cursor
// over an item. An addon with nothing to draw in that world draws nothing CORRECTLY — and
// that is most of this list. Measured on the vanilla corpus rather than assumed: of 48 rows
// checked, 6 ship no XML at all (pure libraries — Ace, LibStub, Stubby, DevTools), and of
// the rest the buff bars (CT_BuffMod 29 frames, ElkBuffBar 3, neither hidden at birth) are
// waiting on buffs that a fresh login does not have either.
//
// What the row DOES mean is "nothing here can be ruled out by the other four columns" —
// which is worth printing, and is not the same as "broken".
//
// The instrument change that would sharpen it is a POPULATED session fixture (a buff, a
// target, a live cooldown) so that "nothing to draw" and "failed to draw" stop sharing a
// row. Named here rather than done, because seating content changes the fixture for all
// 218 addons and deserves its own controlled A/B.
// A question, not a defect list: the survey seats a player in an empty world, so an addon
// with nothing to draw there (a buff bar, a pure library) draws nothing correctly.
println!(
"\n DREW NOTHING, and clean on every other column ({}) — a QUESTION, not a defect\n \
list: the seated world has one buff, one target and one running cooldown, so an addon\n \
@@ -596,14 +485,8 @@ fn main() {
}
}
// **The use column** — the only one here that TOUCHES anything. Every number above it asks
// whether something ran or appeared; the director drew the line by hovering Bagnon's freshly
// drawn bag slots and getting a wall of `attempt to call global
// 'ContainerFrameItemButton_OnEnter'` while all five columns above scored it a pass.
//
// Printed as THREE numbers, never one, because "nothing raised" and "nothing was touched" are
// different answers and a probe that quietly drove zero targets reporting "clean" is this
// instrument's own oldest failure mode (`addon_harness::use_probe`'s header).
// The use column, the only one that touches anything: three numbers, because "nothing
// raised" and "nothing was touched" are different answers (`addon_harness::use_probe`).
let (survived_use, raised_use, untouched_use) = (
reports
.iter()
@@ -635,8 +518,7 @@ fn main() {
frames each addon painted itself)",
addon_harness::MAX_USE_TARGETS
);
// The list this column exists to be able to print: an addon whose UI is fully on screen and
// falls over the moment anyone uses it.
// Addons whose UI is fully on screen and falls over the moment anyone uses it.
let inert: Vec<String> = reports
.iter()
.filter(|r| r.used.verdict() == addon_harness::Used::Raised)
@@ -684,14 +566,8 @@ fn main() {
}
}
// **What was WARNED about, ranked** — the channel that reached nobody until 2135. It is
// printed after the error rankings and before the distribution on purpose: these are not
// blockers (nothing raised, the addon is running), so they must not outrank a row somebody is
// stuck on — but they are the only column that can see an addon quietly getting the wrong
// thing, which is the class `render` was added for and reaches later.
//
// Ranked by how many ADDONS hit each row, not by total occurrences: a warning one addon fires
// in an OnUpdate would otherwise bury one that fifty addons hit once.
// What was warned about: not blockers, so after the error rankings, ranked by how many addons
// hit each row so one noisy OnUpdate does not bury a warning fifty addons hit once.
let mut warned: std::collections::BTreeMap<String, usize> = Default::default();
for r in &reports {
let mut seen: std::collections::BTreeSet<String> = Default::default();
@@ -709,10 +585,7 @@ fn main() {
println!(
"\n what was WARNED about ({addons} addons raised at least one, by addon count):"
);
// Through `ranked`, not a bare `take(12)`: this list is a queue like every other one here,
// and a silent cut reads as "that is the whole list" (1242's rule, which this block was
// written outside of). It cost a measurement — asking the corpus how many `SetPoint`
// targets fail to resolve, the answer sat below the cut and the column read zero.
// Through `ranked`, so the cut is stated.
ranked(rows, 12);
}
@@ -733,11 +606,9 @@ fn main() {
println!(" {label:>5} {n:>4} {}", "#".repeat(n.min(60)));
}
// **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 (`0x6edaa0`).
// Nothing is subtracted from the headline — 1213 — so both readings stay available.
// Whose package is incomplete: a `.toc` entry whose file the addon does not ship is the
// addon's defect, and the reference logs `Couldn't open %s` and carries on (`0x6edaa0`).
// Nothing is subtracted from the headline.
let own: Vec<&addon_harness::AddonReport> = reports
.iter()
.filter(|r| !r.absent_own_files.is_empty())
@@ -746,10 +617,7 @@ fn main() {
.iter()
.filter(|r| !r.absent_foreign_files.is_empty())
.collect();
// **"the WHOLE reason" is a claim, and it is checked before it is printed.** An addon can be
// short a file AND raise somewhere else; saying "this is why it fails" on the strength of
// `!loaded` alone would be the same overclaim this column exists to stop. It holds only when
// the absent entries account for every load error the addon has.
// "The whole reason" holds only when the absent entries account for every load error.
let sole_cause = |r: &addon_harness::AddonReport| {
!r.loaded && r.errors.len() == r.absent_own_files.len() + r.absent_foreign_files.len()
};
@@ -797,80 +665,56 @@ fn main() {
);
}
// What actually STOPPED them — the ranked first error. Read this before the demand list: a
// wall 60 addons hit is worth more than a verb 60 addons would like (decision 1193).
// What stopped them: the ranked first error.
println!("\n what stopped them (addons whose FIRST load error was each):");
for (err, count) in addon_harness::blockers(&reports).into_iter().take(12) {
println!(" {count:>4} {err}");
}
// Templates an addon names in `CreateFrame(..., "Template")` that we have never declared
// (decision 1203). Its own list because it is invisible to every other number here: an
// unresolved template raises no load error, so the addon scores as a pass and paints nothing.
// Templates named in `CreateFrame(..., "Template")` that we never declared: an unresolved
// template raises no load error, so the addon passes and paints nothing.
let templates = addon_harness::template_demand(&reports);
if !templates.is_empty() {
println!("\n most-wanted missing TEMPLATES (addons naming each in CreateFrame):");
ranked(templates, 12);
}
// The same question over the OTHER axis, and the one that actually moves the headline: a
// template named in an addon's own XML `inherits=`. Unlike the list above this failure is
// usually LOUD — the element's `<OnLoad>` fires at load and its first line is normally
// `getglobal(this:GetName().."Text")` — which is why transcribing the reference's shared kit
// was worth twelve addons while the CreateFrame list predicted none of them. Printed second
// and separately because merging the two would hide exactly that difference.
// Templates named in an addon's own XML `inherits=`: usually loud, since the element's
// `<OnLoad>` fires at load. Kept apart from the list above.
let inherits = addon_harness::inherits_demand(&reports);
if !inherits.is_empty() {
println!("\n most-wanted missing TEMPLATES (addons naming each in an XML inherits=):");
ranked(inherits, 12);
}
// Frames and tables, ranked separately: a missing function is a Rust verb to write, a missing
// frame is FrameXML to transcribe, and the two queues go to different people. The scan was
// blind to this whole shape until 2026-08-11 — a window 86 addons reach scored 0.
// Frames and tables, ranked separately: a missing function is a Rust verb, a missing frame is
// FrameXML.
let tables = addon_harness::table_demand(&reports);
if !tables.is_empty() {
println!("\n most-wanted missing FRAMES/TABLES (addons indexing each):");
ranked(tables, 16);
}
// **Widget METHODS** — the third queue, and the one this report was blind to while the arc
// spent a day building exactly these (GetTexture, SetShadowColor, SetNonSpaceWrap, GetBackdrop
// all landed within hours). A method is not a global and not an indexed table, so neither list
// above could ever hold one; the only trace a missing method left was an error row reading
// `attempt to call method 'X' (a nil value)`, with the name collapsed away by the very
// normalisation that makes the row readable.
//
// Resolved against the live `__index` dispatcher, not a name list — so a whole *kind* left
// unwired shows up here as loudly as a verb never written. It over-reports (a scanner cannot
// type the receiver of a `:` call); `AddonReport::missing_methods` states exactly how far, and
// `--why <name>` now reads any row back by name.
// Widget methods: neither a global nor an indexed table, so only these tables see them.
// Resolved against the live `__index` dispatcher; it over-reports, since a scanner cannot type
// a `:` call's receiver (`AddonReport::missing_methods`).
let methods = addon_harness::method_demand(&reports);
if !methods.is_empty() {
// The header states BOTH limits, because the list read as a build queue and is not one
// (decision 1240). It counts addons that NAME the verb, which is neither "addons blocked
// by it" nor "call sites": its top three were once RegisterTabCompletion/IsModule/
// IsModuleActive — AceConsole-2.0's and AceAddon-2.0's own methods on their own objects,
// one library file replicated into 56 and 38 addons — and its fourth, EnableKeyboard, was
// a real absent Frame verb that was blocking none of its 8, every one of which died
// earlier and elsewhere.
// It counts addons that name the verb, neither addons blocked by it nor call sites: a
// library replicated into many addons inflates a row.
println!("\n most-wanted missing METHODS — addons that NAME each as obj:Name(), and");
println!(" no widget answers. NOT a blocker list and NOT a build queue:");
println!(" · a big number is usually ONE library file replicated (1207/1210), and");
println!(" · a big number is usually ONE library file replicated, and");
println!(
" · a third-party library's own method on its own object is not ours to write."
);
println!(" Open the call site before ranking it (decision 1240).");
println!(" Open the call site before ranking it.");
ranked(methods, 16);
}
// **Per KIND** — the question the table above structurally cannot ask. It resolves a name
// against every probe and stops at the first hit, so a verb wired to one class and forgotten on
// its sibling comes back present: `MessageFrame:AddMessage` scored zero for as long as it
// existed, answered by the ScrollingMessageFrame probe, while three corpus addons had
// `UIErrorsFrame:AddMessage` as their FIRST load error (decision 1228). These rows are the
// call sites whose receiver the survey could TYPE — from a `CreateFrame("Kind", …)` local, or
// from the kind our own arena publishes that name as — asked against that kind alone.
// Per kind: rows whose receiver the survey could type, from a `CreateFrame("Kind", …)` local
// or our arena's kind for that name, asked against that kind alone, so a verb wired to one
// class and missing on its sibling shows.
let by_kind = addon_harness::kind_method_demand(&reports);
if !by_kind.is_empty() {
println!(
@@ -880,11 +724,8 @@ fn main() {
ranked(by_kind, 16);
}
// ...and the residue: a receiver nothing could type, on a name whose answer DEPENDS on the
// kind. An upper bound, printed rather than swallowed — resolving these against the whole probe
// set and calling them present is the exact blindness above. The length of this table measures
// the ATTRIBUTOR, not the widget surface: every receiver shape the scan learns to type moves
// rows out of it.
// Receivers nothing could type, on a name whose answer depends on the kind: an upper bound
// that shrinks as the scan learns to type receivers.
let ambiguous = addon_harness::ambiguous_method_demand(&reports);
if !ambiguous.is_empty() {
println!(
@@ -896,11 +737,8 @@ fn main() {
ranked(ambiguous, 12);
}
// The other half of the same scan, and never merged into it: methods addons call **behind a
// feature test**, so nobody is stuck on one. `if sliderFrame.SetTopLevel then` is Dewdrop's,
// and `SetTopLevel` is a real 1.12 widget method 60 corpus addons quietly do without. Building
// one of these fixes no error and improves N addons' behaviour, which is a different decision
// from the list above — so it is a different table.
// Methods addons call behind a feature test (`if sliderFrame.SetTopLevel then`): nobody is
// stuck on one, so they are a separate table.
let optional = addon_harness::optional_method_demand(&reports);
if !optional.is_empty() {
println!("\n ...and methods they FEATURE-TEST and work around (not blockers):");
@@ -912,12 +750,8 @@ fn main() {
if let Some(pattern) = &why {
let hits = addon_harness::blocked_by(&reports, pattern);
// "first error" was a lie in two directions and both cost this instrument a whole class:
// the match ran against the NORMALISED row only, where every quoted name is already `'X'`,
// so `--why GetBackdrop` answered `(none)` while an addon was dying on exactly that; and
// it read only the first error, while a session start keeps firing handlers after one dies.
// A row with no `#` is the addon's first error — the one the tables above rank — so the
// index-less rows still count out to the ranked row exactly.
// Matches the raw text as well as the normalised row, over every error, not just the first.
// A row with no `#` is the addon's first error, the one the tables above rank.
println!(
"\n errors matching {pattern:?} ({}) — a row without a '#' is the FIRST error, i.e. \
the one the tables above rank:",
@@ -925,10 +759,8 @@ fn main() {
);
for (name, err) in &hits {
println!(" {name}");
// Two lines, not one. The first is the message; the SECOND is mlua's first traceback
// frame, and for the row this instrument is most often pointed at that frame is the
// whole answer — `in local '(for generator)'` is what tells a generic-for
// (decision 1202) apart from any other call of a table value.
// The message, then mlua's traceback frames; the first tells a generic-for
// (`in local '(for generator)'`) apart from any other call of a table value.
for line in err.lines().take(WHY_TRACEBACK_LINES) {
println!(" {}", line.trim());
}
@@ -939,10 +771,7 @@ fn main() {
normalised row does either)"
);
}
// ...and the same question of the DEMAND tables, which no read-back reached until now.
// Every ranking above is a scan, and this arc has found fiction at the top of three of
// them (1210, 1218, 1227); the rule that came out of it is "open the corpus line before
// quoting the row", and this is what makes that one command instead of a code edit.
// ...and the same question of the method demand tables.
let rows = addon_harness::method_rows_matching(&reports, pattern);
println!(
"\n method-table rows matching {pattern:?} ({}) — which addons carry the row, and \
@@ -955,11 +784,8 @@ fn main() {
if rows.is_empty() {
println!(" (none — no addon's method tables contain that text)");
}
// ...and WHICH ADDONS each demand ranking counted. The rankings print a number per name and
// nothing could ask what it was made of, so a row could disagree with the corpus and no
// command would say so. It happened: `GetChannelList` ranked 4 while exactly ONE addon's
// source names it, and finding that took a hand-rolled grep across a symlinked corpus.
// A count you cannot open is a claim, not a measurement.
// ...and which addons each demand ranking counted, so a row can be checked against the
// corpus.
for (label, rows) in [
(
"globals",
@@ -997,25 +823,21 @@ fn main() {
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
// The SESSION column, not just the load one. `loaded` means "no LOAD errors" and
// always has (1213); an addon that loads clean and dies in its PLAYER_LOGIN handler
// read as a pass here, which is the exact overstatement 1213 measured at four to one.
// "does this addon actually work" is the two columns together, so print both.
// The session column beside the load one: an addon can load clean and die in its
// PLAYER_LOGIN handler.
let session = match r.session_errors.first() {
None if r.loaded => "session=ok".to_string(),
None => "session=-".to_string(),
Some(e) => format!("session: {}", e.lines().next().unwrap_or(e)),
};
// The render verdict, with the quad count and the frames it was charged to — the row
// that turns "this addon is fine" into "this addon is fine AND you can see it".
// The render verdict, with the quad count and the frames it was charged to.
let drew = format!(
"drew={}({})",
r.render.drew().word(),
r.render.own_quads + r.render.overlay_quads
);
// The use verdict, ALWAYS with the target count beside it — a bare `used=ok` would be
// unreadable exactly where it matters, because it reads the same whether the probe
// drove eight of the addon's frames or none at all.
// The use verdict, always with the target count: `used=ok` reads the same whether the
// probe drove eight frames or none.
let used = format!(
"used={}({}/{})",
r.used.verdict().word(),
@@ -1034,9 +856,8 @@ fn main() {
);
}
}
// The per-addon roster: `<name> ok|fail`, sorted, one per line — a shape `--diff` can read back
// and a human can eyeball. `ok` is the SESSION column (loaded AND no session error), because
// that is the one this arc treats as "works" (1213).
// The per-addon roster: `<name> ok|fail`, sorted, one per line. `ok` is the session column
// (loaded and no session error).
let roster: Vec<(String, bool)> = {
let mut v: Vec<(String, bool)> = reports
.iter()
@@ -1063,19 +884,9 @@ fn main() {
if let Some(path) = &diff {
match std::fs::read_to_string(path) {
Ok(text) => {
// **The digest gate.** `--status` stamps the roster's header with the FrameXML
// digest it was taken at, and a delta is only attributable when this run's matches:
// in a dev build `assets/ui` is read from the SOURCE TREE, so any *other* session
// landing an interface change moves the headline under you with no rebuild of
// yours. The stamp was written for this and then never checked, so the check was
// the reader's to remember — which is exactly how 1209's wrong attribution reached
// a decision record, and it nearly happened again on the run that added this gate
// (a rebase pulled in a neighbour's interface change between the baseline and the
// measurement).
//
// So this REFUSES rather than warns. A warning printed beside a `net +0` still
// leaves the number sitting there to be quoted; withholding the number is the only
// form that cannot be misread.
// The digest gate: a delta is attributable only when this run's FrameXML digest
// matches the roster's, since a dev build reads `assets/ui` from the source tree.
// It refuses rather than warns, so a mismatched number is never printed.
let base_digest = text
.lines()
.find(|l| l.trim_start().starts_with("# per-addon status"))
@@ -1085,8 +896,8 @@ fn main() {
println!("\n DIFF vs {path}: REFUSED — the baseline is at a different tree");
println!(" baseline digest {was} this run {now}");
println!(
" A delta across two digests is not attributable to your change \
(decision 1209).\n Re-take the baseline on this tree: \
" A delta across two digests is not attributable \
to your change.\n Re-take the baseline on this tree: \
--status <file>, then re-run --diff."
);
return;
@@ -1122,8 +933,7 @@ fn main() {
gained.len(),
lost.len()
);
// Both lists always print, even when empty. A silent "lost" section would let a
// regression read as a clean run, which is the fault this whole mode exists for.
// Both lists always print, even when empty.
for (label, list) in [("GAINED", &gained), ("LOST", &lost)] {
println!(" {label} ({}):", list.len());
for n in list.iter() {
File diff suppressed because it is too large Load Diff
+23 -71
View File
@@ -1,35 +1,10 @@
//! **The read-back column — ask the VM instead of guessing at it.**
//! The read-back probe: load one addon as the survey does, drive the session start, then run
//! the caller's Lua, cursor moves and ticks against the VM left standing.
//!
//! [`super::survey`] answers *how many* addons work and *what row* each one died on. It cannot
//! answer the next question, which is the one every fix actually turns on: **why is this
//! particular global nil?** The report's `--why` opens the error text; nothing opened the *state*
//! the error was raised against.
//!
//! So this loads **one** addon exactly the way the survey loads it, drives the same session start,
//! and then evaluates Lua of the caller's choosing against the VM that is left standing.
//!
//! ## Two properties it has to have, and both are about not lying
//!
//! **The environment is the corpus's, never the selection's.** The registry every VM is seated
//! with, and the case-folded installed set dependency resolution consults, are built from the
//! *whole folder* — exactly as [`super::survey`] builds them — so probing `KLHThreatMeter` out of
//! a 219-addon root produces the same VM its row in the full run had. Building a registry of one
//! would quietly answer a different question: `GetAddOnInfo` is how AceAddon and AceLibrary find
//! their dependencies, and against a registry of one they take the "nothing is installed" path
//! (the fault [`super::survey`]'s registry comment records at corpus scale).
//!
//! **It stops at session start, before the render and use probes.** Those two drive input into the
//! addon's own frames — hover, click, drag — and standing up the method oracle writes sixteen
//! widgets and a global into the VM. A read taken after them answers "what is true once the
//! harness has finished poking it", which is not the state a player is in and not the state an
//! error was raised in. `ADDON_LOADED` → `VARIABLES_LOADED` → `PLAYER_LOGIN` →
//! `PLAYER_ENTERING_WORLD` + the ticks is the moment this reads, and it is the moment the session
//! column's errors come from.
//!
//! ## What it is worth
//!
//! It is a **debugger, not a measurement**. An eval can mutate the VM, so nothing here prints a
//! column and no number from a probe run belongs in a record — quote [`super::survey`] for that.
//! The registry and installed set are built from the whole folder, as the survey builds them:
//! against a registry of one, AceAddon and AceLibrary find no dependencies. It reads the state
//! after `PLAYER_ENTERING_WORLD` and the ticks, before the render and use probes touch the VM.
//! An eval can mutate the VM, so a probe is a debugger; the survey is the measurement.
use std::collections::BTreeSet;
use std::path::Path;
@@ -41,21 +16,17 @@ use benilla_ui::toc::Toc;
#[derive(Debug, Clone, Default)]
pub struct ProbeOutcome {
pub name: String,
/// Load-time failures, verbatim — the same list [`super::AddonReport::errors`] carries.
/// Load-time failures, verbatim, as [`super::AddonReport::errors`] carries them.
pub load_errors: Vec<String>,
/// What its handlers raised while the session start was driven.
pub session_errors: Vec<String>,
/// `(chunk, answer)` per `--eval`, in the order asked. An answer is `= <value>` or
/// `ERROR: <message>`; the chunk that raises does not stop the ones after it.
/// `(chunk, answer)` per step in order; an answer is `= <value>` or `ERROR: <message>`, and a
/// raise does not stop the steps after it.
pub answers: Vec<(String, String)>,
}
/// Wrap a caller's chunk so a raise comes back as a value instead of killing the probe.
///
/// **`pcall` + `tostring`, and only the FIRST result.** Multi-return would need `table.getn` or
/// `select`, and this VM is deliberately 5.0-shaped (decisions 1194/1215) — a probe that depended
/// on which of those the dialect layer publishes would be an instrument with a dialect bug in it.
/// A caller that wants more concatenates its own string, which is what `..` is for.
/// Wrap a chunk in `pcall` + `tostring` so a raise comes back as a value; only the first result
/// is returned, so the wrapper needs nothing beyond Lua 5.0.
fn wrapped(chunk: &str) -> String {
format!(
"local __ok, __v = pcall(function() {chunk} end)\n\
@@ -63,37 +34,23 @@ fn wrapped(chunk: &str) -> String {
)
}
/// One step of a probe run, in the order the caller asked for it.
///
/// A probe used to be a list of chunks alone, which cannot ask the question half of this corpus
/// is about: an addon's map, bar and tooltip behaviour is **hover-driven**, and the state a read
/// finds with the cursor parked off-screen is not the state a player is in. Cartographer's world
/// map is the case that forced this — its area label is hidden at every zone change and shown
/// again only by `WorldMapButton`'s `OnEnter`, so `WorldMapFrameAreaLabel:IsShown()` reads `nil`
/// in a cursor-less VM whether the hover works or not. Interleaving the move with the reads is
/// what tells those two apart.
/// One step of a probe run. Much addon state is hover-driven, so moves interleave with reads.
#[derive(Debug, Clone)]
pub enum Step {
/// Lua to evaluate against the VM as it stands.
Eval(String),
/// Move the cursor to `(x, y)` in UI units (y-up from the bottom-left), firing the real
/// `OnLeave`/`OnEnter` pair exactly as [`UiScript::mouse_move`] does for the app.
/// `OnLeave`/`OnEnter` pair as [`UiScript::mouse_move`] does for the app.
Mouse(f32, f32),
/// Advance the engine one frame of `secs` — the `OnUpdate` pump. An addon that hooks a
/// FrameXML `OnUpdate` (Cartographer secure-hooks `WorldMapButton_OnUpdate`) raises there and
/// nowhere else, so a probe that never ticks reports it clean. The answer is what the tick
/// raised, or `no errors`.
/// Advance one `OnUpdate` frame of `secs`; the answer is what the tick raised, or `no errors`.
Tick(f32),
}
/// Load `name` out of `root` the way the survey does, drive the session start, then run each
/// [`Step`] against the VM that is left.
///
/// `None` when the folder has no manifest — the same refusal [`super::survey`] makes by filtering.
/// [`Step`] against the VM that is left; `None` when the folder has no manifest.
pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
let toc_path = super::manifest_path(root, name)?;
// Decoded, not `read_to_string`'d, for the reason `survey_one` states: a cp1252 manifest read
// as UTF-8 parses as an EMPTY toc, and an addon with no files reads as a clean pass.
// Decoded: a cp1252 manifest read as UTF-8 parses as an empty toc and a false clean pass.
let toc = Toc::parse(&benilla_ui::source::decode(
&std::fs::read(&toc_path).unwrap_or_default(),
));
@@ -112,10 +69,8 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
};
script.set_instruction_budget(super::ADDON_INSTRUCTION_BUDGET);
script.set_screen_size(1024.0, 768.0);
// `None` **saved-variable** roots: a probe must never read or write the director's real ones,
// for the same reason the survey does not call `finish_ui_load` (1213 §4). The AddOns root is
// passed, exactly as [`super::survey_one`] passes it — a probe VM that answers `MISSING` to
// every `LoadAddOn` is not the VM the row came from (decision 2102).
// No saved-variable roots, so a probe never touches a player's saved variables; the AddOns
// root is passed as the survey passes it, so `LoadAddOn` resolves.
script.register_addons(registry, Some(root.to_path_buf()), None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
@@ -131,9 +86,8 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
);
let load_errors = super::load_addon_files(&script, root, name, &toc).errors;
// Same stamp the survey applies, for the same reason: the registry must agree with the VM
// about what is loaded, or `IsAddOnLoaded` and every dependency verdict answer for a session
// that does not exist. The chain stamped itself as it loaded (2166); this is the surveyed one.
// The registry must agree with the VM, or `IsAddOnLoaded` answers for another session; the
// dependency chain stamped itself as it loaded.
script.mark_addon_loaded(name);
let session_errors = super::drive_session_start(&mut script, name, &installed);
@@ -143,9 +97,7 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
Step::Eval(chunk) => {
let answer = script
.eval::<String>(&wrapped(chunk))
// A raise HERE is the wrapper failing to compile the caller's chunk — a syntax
// error in what they typed — not the chunk raising, which `pcall` already
// caught.
// Only a syntax error in the chunk reaches here; `pcall` caught any raise.
.unwrap_or_else(|e| format!("SYNTAX: {e}"));
(chunk.clone(), answer)
}
@@ -163,8 +115,8 @@ pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
)
}
Step::Mouse(x, y) => {
// `resolve` first: the hit-test reads resolved rects, and a frame the addon
// created or moved since the last resolve has none (`pointer`'s module doc).
// Resolve first: the hit-test reads resolved rects, which a new or moved frame
// lacks until then.
script.resolve();
script.mouse_move(*x, *y);
let focus = script
@@ -1,8 +1,4 @@
//! **The probe's can-it-fail proof**, plus the one property that makes its answers worth
//! anything: the VM it reads is the corpus's, not the selection's.
//!
//! A debugger that quietly answers a different question from the report it exists to explain is
//! worse than no debugger, because every conclusion drawn from it looks sourced.
//! Tests for the read-back probe: it reads after the session start, in the corpus's VM.
use std::path::{Path, PathBuf};
@@ -46,14 +42,8 @@ impl Drop for Fixtures {
}
}
/// **The probe reads AFTER the session start, and a raise is a value, not a stop.**
///
/// Three claims in one addon. A global written at file scope is readable (the load ran); a global
/// written only from a `PLAYER_LOGIN` handler is readable too (**the session start ran** — this is
/// the claim that separates this from a bare load, and the moment the session column's own errors
/// come from); and an eval that raises comes back tagged `ERROR:` **without stopping the eval after
/// it**, because a debugging session that dies on its own first typo is a debugging session spent
/// re-running the command.
/// A file-scope global and a `PLAYER_LOGIN` global are both readable, and a raise comes back as
/// `ERROR:` without stopping the next eval.
#[test]
fn the_probe_reads_the_vm_after_the_session_start() {
let fx = Fixtures::new("after");
@@ -98,13 +88,7 @@ fn the_probe_reads_the_vm_after_the_session_start() {
);
}
/// **The environment is the whole folder's, never the probed addon's.**
///
/// `GetAddOnInfo` is how AceAddon and AceLibrary — the two most replicated files in the corpus —
/// find their dependencies, so a probe built against a registry of one would send half the
/// ecosystem down its "nothing is installed" path and answer a different question from the survey
/// row it was opened to explain. The sibling here is never surveyed and never loaded; it must
/// still be *installed*.
/// The registry is the whole folder's: a sibling that is never loaded is still installed.
#[test]
fn the_probe_sees_the_whole_folder_installed() {
let fx = Fixtures::new("registry");
@@ -131,16 +115,14 @@ fn the_probe_sees_the_whole_folder_installed() {
out.answers[1].1, "= 2",
"the registry is the folder's, not the selection's"
);
// ...and installed is not loaded: one addon per VM is the survey's own isolation rule, and a
// probe that silently ran the neighbours would attribute their globals to the addon in hand.
// Installed is not loaded: one addon per VM, as in the survey.
assert_eq!(
out.answers[2].1, "= nil",
"an installed sibling must NOT have been run"
);
}
/// A folder with no manifest is `None`, not an empty outcome — the same refusal the survey makes
/// by filtering, said out loud so the caller can tell "no such addon" from "that addon is silent".
/// A folder with no manifest is `None`, not an empty outcome.
#[test]
fn a_folder_without_a_manifest_is_refused() {
let fx = Fixtures::new("nomanifest");
+48 -162
View File
@@ -1,48 +1,14 @@
//! **The render column — "did this addon put anything on screen?"**
//! The render column: which painting quads come from widgets the addon created.
//!
//! Every other column in [`super`] is load- or event-based: they ask whether something *raised*.
//! None of them could ask whether anything was **drawn**, and that gap is not theoretical — the
//! director installed Bagnon, opened their bags, and saw a title and a gold line with **no bag
//! slots at all**, while the survey scored it `loaded, missing=0, session=ok, probe=ok`. The cause
//! raised nothing anywhere (`crate::ui_script::bagnon_render_tests` is the reproduction), so no
//! error-shaped instrument could ever have found it.
//! [`RenderBaseline::of`] snapshots every live draw target before the addon runs, and anything
//! new afterwards is the addon's, anonymous frames included; handles are generational, so a
//! reused slot is never taken for a survivor. [`Drew`] splits a window of the addon's own from an
//! overlay on an existing one.
//!
//! ## The oracle: a handle diff, not a name and not a total
//!
//! [`RenderBaseline::of`] snapshots every live draw target *before* the addon runs a line; anything
//! new afterwards was created by the addon. Both obvious alternatives are wrong, and wrong in ways
//! this arc has already been bitten by:
//!
//! - **A with/without quad-count diff** reads zero for an addon that *replaces* rather than adds.
//! Bagnon takes the bags over: our own `BenillaBagFrame` stops drawing as Bagnon starts, and the
//! total barely moves whether Bagnon painted sixteen slots or none. (Measured: 16 either way, in
//! the first draft of the reproduction test.)
//! - **A name-prefix match** cannot see an anonymous frame, and `!OmniCC`'s entire visible output
//! hangs off `CreateFrame("Frame", nil, cooldown:GetParent())` — no name at all.
//!
//! Handles are generational, so a destroyed-and-reused slot can never be mistaken for a survivor.
//!
//! ## Three answers, because the interesting one is the middle
//!
//! [`Drew`] separates an addon that built **its own window** from one that **painted over ours**,
//! and that split is usually the most useful thing here: it points straight at the cause. The two
//! addons the director has verified by eye land on opposite sides of it — `!OmniCC` works and is
//! an [`Drew::Overlay`], Bagnon has to build its own slot buttons and was a [`Drew::Nothing`] — and
//! a check that could only see one of the two shapes would have scored the working addon zero.
//!
//! ## What it over- and under-reports, stated rather than hidden
//!
//! - **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 (`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
//! handle and scores [`Drew::Nothing`]. Catching that needs a per-quad content diff, which is a
//! different instrument; it is named here so nobody reads a `nothing` row as proof.
//! - **The world is minimal**, exactly as [`super::drive_session_start`] says: no real bank, no
//! real raid, no real quests. An addon whose window only opens on state we do not simulate draws
//! nothing here and would draw in play.
//! It over-reports: a quad off-screen or under another window counts. It under-reports an addon
//! that only changes an existing widget in place (`SetTexture`, `SetBackdrop`), which makes no new
//! handle. The session world is minimal, so a window that opens only on unsimulated state (a
//! bank, a raid) draws nothing here.
use std::collections::{BTreeSet, HashSet};
@@ -51,7 +17,7 @@ use benilla_ui::order::ZTarget;
use benilla_ui::script::{QuadContent, UiScript};
use benilla_ui::widget::FrameHandle;
/// Every widget that existed before an addon ran — the set the render probe diffs against.
/// Every widget that existed before an addon ran, the set the render probe diffs against.
pub(super) struct RenderBaseline {
targets: HashSet<ZTarget>,
frames: HashSet<FrameHandle>,
@@ -71,45 +37,33 @@ impl RenderBaseline {
Self { targets, frames }
}
/// Did this frame exist before the addon ran a line?
///
/// The one question [`super::use_probe`] asks of the baseline: a pointer event is only the
/// addon's to be judged by when the frame that captures it is one the addon itself created.
/// Whether `frame` existed before the addon ran; [`super::use_probe`] judges only pointer
/// events captured by frames the addon created.
pub(super) fn is_pre_existing(&self, frame: FrameHandle) -> bool {
self.frames.contains(&frame)
}
}
/// One place on screen the addon **painted** — a candidate for [`super::use_probe`] to point at.
///
/// Emitted by the same walk that attributes the render column, and that is deliberate: "which
/// pixels are this addon's" is one question with one answer, and computing it twice is how two
/// instruments start disagreeing about the same addon.
/// One place the addon painted, for [`super::use_probe`] to point at; emitted by the same walk
/// that attributes the render column, so the two never disagree.
pub(super) struct Painted {
/// The frame the quad was charged to. Only used to **dedupe** candidates — sixteen quads on
/// one button should not spend sixteen hit-tests — never to decide what gets driven; the
/// hit-test does that, because the frame that owns a painted region is often not the frame
/// that would actually eat a click on it.
/// The frame the quad was charged to, used only to dedupe candidates: the hit-test decides
/// which frame a click reaches.
pub(super) owner: FrameHandle,
/// The centre of the painted rect: where a player's cursor would be to be "on" this pixel.
/// The centre of the painted rect.
pub(super) point: (f32, f32),
}
/// What an addon put on screen.
///
/// Ordered by how much of the screen the addon owns, so a `>` comparison reads the way it looks.
/// What an addon put on screen, ordered by how much of the screen it owns.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum Drew {
/// Not one painting quad came from anything this addon created. Bagnon's row before the fix,
/// and the answer this column was built to be able to give.
/// No painting quad came from anything the addon created.
#[default]
Nothing,
/// It painted, but only from widgets hanging off frames that already existed — an overlay on
/// somebody else's window. `!OmniCC`'s countdown text is the reference case: an anonymous
/// `Frame` + `FontString` parented to one of our action buttons' cooldowns.
/// Painted only from widgets hung off pre-existing frames, such as `!OmniCC`'s anonymous
/// countdown text on an action button's cooldown.
Overlay,
/// It painted from a window of its own — a frame tree rooted outside everything that existed
/// before it. Bagnon's inventory window, once it works.
/// Painted from a frame tree of its own, rooted outside everything pre-existing.
Own,
}
@@ -131,12 +85,8 @@ pub struct RenderReport {
pub own_quads: usize,
/// Painting quads from widgets the addon hung off a frame that already existed.
pub overlay_quads: usize,
/// **Every** distinct named frame, not a sample — [`MAX_NAMED_FRAMES`] bounds only what is
/// PRINTED, because a caller that asks "did it draw an item slot?" cannot answer that from a
/// truncated list, and one of ours silently could not.
///
/// The distinct named frames those quads were charged to, nearest-named-ancestor per quad, so
/// a `nothing`/`own` row can be read back to a place in the addon's own code. Capped, sorted.
/// Every distinct named frame the quads were charged to (nearest named ancestor), sorted and
/// uncapped: [`MAX_NAMED_FRAMES`] bounds only the print.
pub frames: Vec<String>,
}
@@ -150,64 +100,23 @@ impl RenderReport {
}
}
/// How many named frames a row PRINTS. Enough to name the window; not enough to be a dump.
///
/// **This bounds the display, not the collection, and that distinction is the whole of 1242.** It
/// used to stop the `named` set filling at 6 — so a row kept the first six names *encountered* and
/// then rendered them through a `BTreeSet`, i.e. sorted. The output therefore read as an
/// alphabetical list of what an addon drew while actually being an arbitrary six of N, and nothing
/// said so.
///
/// That is not hypothetical damage. `render_tests`'
/// `the_directors_two_verified_addons_come_out_on_opposite_sides` asserts Bagnon draws frames named
/// `BagnonItem*` — the director's original complaint. It passed only because `BagnonItem1` happened
/// to be among the first six quads attributed. Seating the player's PURSE in the session fixture
/// added `BagnonMoneyFrameSilverButton` to the same window, which took the sixth slot, evicted
/// `BagnonItem1`, and made the test assert — correctly, against the data it was given — that Bagnon
/// draws no item slots at all. The addon had not changed.
///
/// So the set now collects everything and the cap applies where the constraint actually is: the
/// printed line. 1242's rule, in the second instrument to break it.
/// How many named frames a row prints. It bounds the display, never the collection: a set capped
/// while filling keeps an arbitrary few that then print sorted, as if complete.
pub const MAX_NAMED_FRAMES: usize = 6;
/// Drive the entry points that make a UI **visible**, then attribute every painting quad.
/// Open the entry points that make a UI visible, tick, then attribute every painting quad.
///
/// **This is a different probe from [`super::drive_ui_probe`], not a duplicate of it**, and the
/// difference is the whole point: that one *toggles* — open then close — because what it measures
/// is whether an override raises on both paths. A render read after it sees a closed window. This
/// one only ever **opens**, and leaves everything up, because what it measures is what is on the
/// screen at the end.
///
/// The entry points are chosen because a corpus addon is measured to replace or hook each:
///
/// | driven | why |
/// |---|---|
/// | `OpenBackpack()` | Bagnon replaces it — and with an *open*, not a toggle, unlike its `ToggleBackpack`/`OpenAllBags` |
/// | `CooldownFrame_SetTimer` on a real button | `!OmniCC` hooks exactly this global; its text exists only while a cooldown runs |
/// | `ActionButton_Update` | zBar, zBarEx, CT_BarMod |
/// | a shown `GameTooltip` | the hover class (decision 1220) — left SHOWN, where the UI probe hides it |
///
/// Then ten ticks, because a great deal of addon painting happens on the first `OnUpdate` —
/// OmniCC's countdown text is written there and nowhere else, so a probe that never ticked would
/// score our one director-verified working addon as blank.
///
/// Returns the report **and the places the addon painted**, in painter order, for
/// [`super::use_probe`] to point at. Nothing else in the survey knows which pixels are the addon's,
/// and it should stay that way: one attribution, one answer.
/// Unlike [`super::drive_ui_probe`], which toggles open then closed, this only opens and leaves
/// everything up. It opens the backpack, updates action button 1 and starts its cooldown, and
/// shows the tooltip, the entry points corpus addons replace or hook; then ten ticks, since much
/// addon painting happens in the first `OnUpdate`. Returns the report and the places the addon
/// painted, in painter order, for [`super::use_probe`].
pub(super) fn measure_render(
script: &mut UiScript,
baseline: &RenderBaseline,
) -> (RenderReport, Vec<Painted>) {
// A REAL ACTION on slot 1, with a real icon and a running cooldown.
//
// Not decoration — without it this column scored `!OmniCC` as blank, and `!OmniCC` is on the
// director's screen. Its `OnUpdate` writes the countdown only `if floor(remain + 0.5) > 0 and
// this.icon:IsVisible()`, and on an empty bar `ActionButton1Icon` is hidden, so the text frame
// is created, shown, and never given a single character. An empty action bar is a state a
// level-60 session does not present; `seat_a_session`'s argument, one slot wide.
//
// Seated HERE rather than in `seat_a_session` on purpose (1225's rule): this runs after every
// other column has been read, so no number beside this one can be perturbed by it.
// A real action with an icon on slot 1: cooldown-count addons write text only while the
// button's icon is visible. Seated here, after every other column is read, so it perturbs none.
script.set_action(
1,
Some(benilla_ui::script::ActionSlot {
@@ -225,11 +134,8 @@ pub(super) fn measure_render(
..Default::default()
}),
);
// A REAL BACKPACK, for the same reason. `GetContainerNumSlots(0)` answers 16 for every
// character in the game; in a VM where it answers 0 a bag addon correctly draws a window with
// no slots in it — which is *exactly* the picture this column was built to be able to catch,
// so leaving it unseated would make the column blind to its own founding case. Sixteen empty
// slots, no items: an addon that paints a slot paints it whether or not something is in it.
// A 16-slot empty backpack, as every character has: with 0 slots a bag addon draws a window
// with no slots, the very failure this column catches.
script.set_container(
0,
Some(benilla_ui::script::ContainerState {
@@ -238,9 +144,8 @@ pub(super) fn measure_render(
slots: std::collections::HashMap::new(),
}),
);
// Guarded on each global being a FUNCTION and each raise swallowed: a raise here is
// `probe_errors`' column to report, and letting one abort the run would silently zero every
// measurement after it (the mis-attribution 1209 was written about).
// Each global is called only if it is a function and each raise is swallowed: raises are the
// `probe_errors` column's, and one here must not zero the measurements after it.
let _ = script.run(
r#"
local function try(fn) if type(fn) == "function" then pcall(fn) end end
@@ -277,20 +182,17 @@ pub(super) fn measure_render(
let mut named: BTreeSet<String> = BTreeSet::new();
let mut painted: Vec<Painted> = Vec::new();
for quad in script.extract() {
// A frame's own slot paints NOTHING (`ui_script::extract`'s converter drops it outright);
// a Backdrop, Texture, Text, Minimap or Cooldown is pixels. Counting the frame slot would
// score every addon that merely *creates* a frame as having drawn, which is the exact
// false pass this column exists to stop.
// A frame's own slot paints nothing; counting it would score every addon that merely
// creates a frame as having drawn.
if matches!(quad.content, QuadContent::Frame) {
continue;
}
// Under-constrained widgets never reach the screen either. Bound here rather than merely
// tested, because the rect is also WHERE the addon painted — the point `use_probe` aims at.
// Under-constrained widgets never reach the screen; the rect is where `use_probe` aims.
let Some(rect) = quad.rect else {
continue;
};
if baseline.targets.contains(&quad.target) {
continue; // ours, drawing as it always did
continue; // pre-existing, not the addon's
}
let Some(owner) = script.target_frame(quad.target) else {
continue;
@@ -300,9 +202,7 @@ pub(super) fn measure_render(
} else {
report.own_quads += 1;
}
// The same quad, kept as a place to point at. An off-screen or zero-area one simply
// hit-tests to nothing later, which is the honest answer for it — no filtering here, or
// the two columns would disagree about which pixels are the addon's.
// Unfiltered: an off-screen quad hit-tests to nothing later.
painted.push(Painted {
owner,
point: centre(rect),
@@ -315,33 +215,19 @@ pub(super) fn measure_render(
(report, painted)
}
/// The middle of a rect, in the y-up UI space `resolve`/`extract`/`hit_test` all share.
/// The middle of a rect, in the y-up UI space `resolve`, `extract` and `hit_test` share.
fn centre(r: Rect) -> (f32, f32) {
((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)
}
/// Does this new frame hang off one of **our widgets**, rather than off the screen?
///
/// The overlay/own split, and it needs more care than "did it reach a pre-existing frame", because
/// *everything* does: `UIParent` is where every window in the game is parented, ours and theirs
/// alike, so that test calls every addon an overlay (it did — this function's first draft failed
/// its own fixture). The seam is one step further in: walk to the nearest ancestor that existed
/// before the addon ran, and ask whether **it** is a top-level frame.
///
/// - nearest pre-existing ancestor is top-level (`UIParent`, `WorldFrame`, the screen root) → the
/// addon put a window on the screen. Bagnon.
/// - nearest pre-existing ancestor is *nested* inside one of our windows → the addon decorated
/// something of ours. `!OmniCC`'s anonymous text frame, whose parent is an action button's
/// cooldown.
/// - no pre-existing ancestor at all (a detached tree) → its own, by the same reading.
///
/// The walk is bounded by the tree's own depth.
/// Whether the nearest pre-existing ancestor of `frame` is nested inside a window (an overlay)
/// rather than top-level (the addon's own window). Every window hangs off `UIParent`, so merely
/// reaching a pre-existing frame proves nothing; a detached tree is the addon's own.
fn hangs_off_one_of_ours(script: &UiScript, baseline: &RenderBaseline, frame: FrameHandle) -> bool {
let mut cursor = Some(frame);
while let Some(fh) = cursor {
if baseline.frames.contains(&fh) {
// `UIParent` and friends carry no parent of their own — reaching one of those means
// the addon reached the *screen*, which is not somebody else's window.
// A pre-existing frame with no parent is the screen root, not another window.
return script.frame_parent(fh).is_some();
}
cursor = script.frame_parent(fh);
@@ -1,22 +1,5 @@
//! **The render column's can-it-fail proof.**
//!
//! A probe that cannot fail is not a probe, and this arc has shipped two that could not: the UI
//! probe swallowed every raise it was built to report (`the_ui_probe_records_what_an_override_raises`
//! is that scar), and the method oracle answered "nothing missing" when it could not run at all.
//! So this file pins **both directions**, twice over.
//!
//! **Synthetically**, on three fixture addons written to be unambiguous — one that paints a
//! texture, one that hangs a FontString off a frame it did not create, and one that creates a
//! shown frame with nothing paintable on it. The third is the sharp one: a frame's own draw slot
//! emits a quad and paints **nothing** (`ui_script::extract`'s converter drops it), so an
//! implementation that counted quads instead of *painting* quads would score it as drawing and
//! every `nothing` row in the corpus would be a lie.
//!
//! **And against reality**, on the two addons the director has verified with their own eyes:
//! `!OmniCC`'s countdown numbers are on their screen, and Bagnon's bag slots were not. Two real
//! addons with known, opposite, human-checked outcomes is a better oracle than any fixture — it is
//! the check that would have caught this class months ago — so
//! [`the_directors_two_verified_addons_come_out_on_opposite_sides`] is the one to read first.
//! Tests for the render column, in both directions: synthetic fixtures that paint a window, an
//! overlay or nothing, and two real corpus addons with known opposite outcomes.
use std::path::{Path, PathBuf};
@@ -83,8 +66,8 @@ impl Drop for Fixtures {
}
}
/// **The proof, in both directions.** An addon that paints must register; one that does not must
/// not — and "creates a frame" is not "paints".
/// An addon that paints registers, one that does not stays `nothing`, and creating a frame is not
/// painting.
#[test]
fn the_render_column_can_fail() {
benilla_formats::wow_data_or_skip!();
@@ -102,7 +85,7 @@ fn the_render_column_can_fail() {
f:Show()
"#,
);
// Paints, but onto one of OUR frames — no window of its own anywhere.
// Paints, but only onto a pre-existing frame.
fx.addon(
"PaintsOnOurs",
r#"
@@ -113,9 +96,7 @@ fn the_render_column_can_fail() {
fs:Show()
"#,
);
// Draws NOTHING — but is far from inert: it creates a frame, shows it, and that frame emits a
// `QuadContent::Frame` entry. Nothing paints from it. This is the fixture that fails a naive
// implementation.
// Creates and shows a frame, whose own `QuadContent::Frame` slot paints nothing.
fx.addon(
"DrawsNothing",
r#"
@@ -135,8 +116,7 @@ fn the_render_column_can_fail() {
.unwrap_or_else(|| panic!("{name} was not surveyed"))
};
// Every fixture must actually have RUN — otherwise "drew nothing" would be measuring a load
// failure, which is the confusion this column exists to end.
// Each fixture must load, or "drew nothing" would measure a load failure.
for name in ["PaintsAWindow", "PaintsOnOurs", "DrawsNothing"] {
assert!(
drew(name).loaded,
@@ -180,26 +160,14 @@ fn the_render_column_can_fail() {
);
}
/// **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
/// creating an **anonymous** frame parented to one of our cooldowns, so any check built on name
/// prefixes or on "did it create a top-level window" scores it zero. Bagnon has to build its own
/// item-slot buttons, and the director saw none of them.
///
/// The two are surveyed together in one throwaway root — copies are not made, the corpus folders
/// are surveyed where they lie — and the column must place them on opposite sides:
/// `!OmniCC` an [`Drew::Overlay`], Bagnon a window of its [`Drew::Own`].
///
/// If this test ever disagrees with the director's eyes, **the test is wrong**.
/// `!OmniCC` paints through an anonymous frame parented to an existing cooldown, an overlay;
/// Bagnon builds its own window of item-slot buttons.
#[test]
fn the_directors_two_verified_addons_come_out_on_opposite_sides() {
fn omnicc_and_bagnon_come_out_on_opposite_sides() {
benilla_formats::wow_data_or_skip!();
// The one resolver, and a skip the gate can refuse (`benilla_formats::install`).
// 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.
// Symlinked into a small root: the full corpus sweep is the `addon_harness` example's job.
let fx = Fixtures::new("oracle");
for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] {
// MONKEY (volumetric fog): use the portable corpus fixture for Windows verification.
@@ -213,17 +181,14 @@ fn the_directors_two_verified_addons_come_out_on_opposite_sides() {
.unwrap_or_else(|| panic!("{name} is not in the corpus"))
};
// The POSITIVE control. A render check that scores this one blank is broken, whatever else it
// gets right — the director sees its numbers.
// The positive control.
assert_eq!(
row("!OmniCC").render.drew(),
Drew::Overlay,
"!OmniCC's countdown text is on the director's screen; it paints via an ANONYMOUS frame \
"!OmniCC's countdown text draws in the live client; it paints via an ANONYMOUS frame \
parented to a cooldown of ours, which is precisely what a name-based check cannot see"
);
// The case that started this. Bagnon builds its own window; before the two fixes it built
// nothing at all and every other column called it fine.
assert_eq!(
row("Bagnon").render.drew(),
Drew::Own,
@@ -235,7 +200,7 @@ fn the_directors_two_verified_addons_come_out_on_opposite_sides() {
.frames
.iter()
.any(|f| f.starts_with("BagnonItem")),
"…and the slots are what it draws — the exact thing the director could not see: {:?}",
"…and the slots are what it draws — the item slots a player sees: {:?}",
row("Bagnon").render.frames
);
}
@@ -1,55 +1,20 @@
//! **The control for the one-VM-per-addon bound** — the whole folder in ONE VM, the way a real
//! client runs it.
//! The control for the survey's one-VM-per-addon bound: the whole folder in one VM, as a real
//! client runs it, walked in load order with each `ADDON_LOADED` at its own position, then one
//! session start. Rows differ from the survey's where an addon relies on a library a neighbour
//! ships.
//!
//! The survey next door loads each addon into a VM of its own, with only its declared dependencies
//! underneath, and says so in its own header: *the headline is a floor, not an estimate*. That
//! bound is real and it is the largest single category left in the corpus's failures — a package
//! that does not ship `Tablet-2.0` and simply expects the library to be there, because in a folder
//! of 219 addons seventy of its neighbours ship a copy and Ace2's registry is one global table.
//! It cannot attribute render or use results, and cannot tell clean from never reached;
//! attribution is by the raising chunk alone.
//!
//! **A bound that is stated and never measured is an excuse.** This is the measurement: one Lua
//! state, every addon in the folder walked in load order, dependencies first, each addon's
//! `ADDON_LOADED` at its own position — then the session start, once, for everybody. What comes
//! back is *which addons raise inside their own files when their neighbours are present*, and the
//! difference between that list and the survey's is exactly the size of the bound.
//! A shared VM is not reproducible: Lua hashes a table key by its pointer, so `pairs()` over an
//! object-keyed registry (every Ace2 library's) walks in a different order per process under
//! ASLR, as the reference's Lua does. So the walk runs [`DEFAULT_RUNS`] times in fresh VMs and
//! each row reports how many runs raised: every run is a real failure, some is order-sensitive.
//!
//! ## What this is NOT
//!
//! Not a replacement for the survey and not a headline. It cannot attribute a *render* or a *use*
//! result (every addon's frames are in one world), it cannot tell "clean" from "never reached",
//! and one addon's runaway is everybody's. Attribution here is by the RAISING CHUNK and nothing
//! else — the same rule the survey applies to session errors, and the only one available when 219
//! addons share a state.
//!
//! ## It is run N times, and the reason is the whole finding
//!
//! **A big shared VM is not reproducible, and that is a property of Lua rather than a defect of
//! ours.** Lua 5.1 hashes a **table key by its POINTER** (`hashpointer`), so `pairs()` over a
//! registry keyed by objects — which is what every Ace2 library's registry is — walks in a
//! different order on every process, because ASLR moves the addresses. Measured directly rather
//! than deduced: twelve fresh tables as keys in one table, iterated, gave `3,7,11,1,…` then
//! `12,5,9,2,…` then `11,1,4,8,…` on three consecutive runs of the same binary.
//!
//! The reference's Lua does exactly the same thing, so this is a fact about the sessions we are
//! modelling and not something to fix. What it means for an INSTRUMENT is that a single run's
//! number cannot be quoted: three consecutive runs here disagreed by one addon and swapped three
//! rows. So the walk runs [`DEFAULT_RUNS`] times in fresh VMs and the verdict is per row:
//!
//! - **raised in EVERY run** — a real failure with the neighbours present;
//! - **raised in SOME** — order-sensitive, named as such and never folded into either count;
//! - **clean in every run** — clean.
//!
//! The per-addon survey next door has been stable run to run (three rosters, byte-identical), and
//! the reason is scale rather than luck: one addon and its declared dependencies rarely build a
//! pointer-keyed registry big enough for the order to change an outcome. "Rarely" is not "never",
//! which is worth knowing about every `--diff` this arc has read as exact.
//!
//! **LoadOnDemand is ignored on purpose.** 62 of the corpus's manifests carry `## LoadOnDemand: 1`
//! and the boot walk skips every one (`0x51f600` loads only records whose LoadOnDemand byte is 0),
//! but the corpus's own addons then demand-load them: `FuBar.lua:1034`'s `LoadLoadOnDemandPlugins`
//! pulls in every installed `FuBar_*` the moment any one of them seats, and Auctioneer's stub does
//! the same for its family. A run that honoured the flag would answer for a session five minutes
//! shorter than the one anybody plays.
//! `## LoadOnDemand: 1` is ignored: the reference's boot walk skips those (`0x51f600` loads only
//! records whose LoadOnDemand byte is 0), but corpus addons demand-load them at once (FuBar's
//! `LoadLoadOnDemandPlugins`, Auctioneer's stub), so honouring the flag would model a shorter
//! session than anyone plays.
use std::collections::BTreeSet;
use std::path::Path;
@@ -61,17 +26,15 @@ use super::{
};
use benilla_ui::toc::Toc;
/// How many times [`survey_together`] repeats the walk before it reports — see the module doc on
/// why one run is not quotable. Three is the smallest number that can tell "always" from
/// "sometimes"; each run is about twenty-five seconds.
/// How many fresh-VM runs [`survey_together`] makes: three is the fewest that tell "always" from
/// "sometimes".
pub const DEFAULT_RUNS: usize = 3;
/// One addon's verdict in the shared VM.
pub struct TogetherRow {
pub name: String,
/// Raises whose first stack frame is inside **this** addon's own folder, load-time and
/// session-time together — the shared VM cannot separate the two windows per addon. From the
/// FIRST run that produced any, so the text is a real traceback and not a merge of several.
/// Raises whose first stack frame is in this addon's folder, load and session together, from
/// the first run that produced any, so the text is one real traceback.
pub errors: Vec<String>,
/// How many of the [`runs`](Self::runs) raised at all.
pub raised_in: usize,
@@ -79,18 +42,18 @@ pub struct TogetherRow {
}
impl TogetherRow {
/// Raised in every run — a failure the neighbours do not fix.
/// Raised in every run: a failure the neighbours do not fix.
pub fn always_raises(&self) -> bool {
self.raised_in == self.runs
}
/// Raised in some runs and not others — the pointer-hash order showing through (module doc).
/// Raised in some runs and not others: the pointer-hash order showing through.
pub fn order_sensitive(&self) -> bool {
self.raised_in > 0 && self.raised_in < self.runs
}
}
/// Load every addon under `root` into one VM and drive the session start — [`DEFAULT_RUNS`] times,
/// in a fresh VM each time, because one run's answer is not reproducible (module doc).
/// Load every addon under `root` into one VM and drive the session start, [`DEFAULT_RUNS`] times
/// in fresh VMs.
pub fn survey_together(root: &Path) -> Vec<TogetherRow> {
let mut merged: Vec<TogetherRow> = Vec::new();
for run in 0..DEFAULT_RUNS {
@@ -123,7 +86,7 @@ pub fn survey_together(root: &Path) -> Vec<TogetherRow> {
merged
}
/// One walk, one VM — the measurement [`survey_together`] repeats.
/// One walk in one VM.
fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
let (names, installed, registry) = corpus(root);
let Ok(mut script) = UiScript::new() else {
@@ -134,12 +97,10 @@ fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
// `seen` spans the WHOLE walk, not one addon: a library folder declared by eighty dependents
// loads once, which is the property that makes this a session rather than eighty of them.
// `seen` spans the whole walk: a library declared by many dependents loads once.
let mut seen: BTreeSet<String> = BTreeSet::new();
// `(folder, its own load failures)` — kept beside the walk rather than read back out of the
// VM, because the diagnostics log caps at 256 distinct rows and evicts, and 219 addons
// overflow it. The survey has the same rule for the same reason.
// `(folder, its load failures)`, kept beside the walk: the VM's diagnostics log caps at 256
// distinct rows and evicts.
let mut load_errors: Vec<(String, Vec<String>)> = Vec::new();
for name in &names {
let Some(toc) = manifest_path(root, name)
@@ -149,17 +110,14 @@ fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
continue;
};
if !seen.insert(name.to_ascii_lowercase()) {
continue; // already pulled in as somebody's dependency
continue; // already loaded as a dependency
}
// Re-armed PER ADDON, exactly as the live walk re-arms it (1306): without the reset one
// runaway spends the whole allowance and every addon after it fails for somebody else's
// loop.
// Re-armed per addon, as the live walk does, so one runaway does not starve the rest.
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
let mut pulled: Vec<LoadedDep> = Vec::new();
load_dependencies(&mut script, root, &toc, &installed, &mut seen, &mut pulled);
// **A dependency's load failures are collected HERE and nowhere else.** In this VM a
// library folder loads exactly once, under whichever dependent reached it first, so it
// never gets a turn of its own in the loop below — and its failures would simply vanish.
// A dependency's load failures are collected only here: it loads once, under the first
// dependent to reach it, and never gets a turn of its own.
for dep in pulled {
load_errors.push((dep.name, dep.files.errors));
}
@@ -179,14 +137,12 @@ fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
script.tick(0.1);
}
// Attribution by the raising chunk. `\<Folder>\` rather than a prefix match, because Lua
// truncates a long chunk name from the LEFT (`...Ons\FuBar_DakSmak\Libs\…`) and the prefix is
// the half it eats.
// Attribution by the raising chunk, matched as `\<Folder>\` rather than a prefix: Lua
// truncates a long chunk name from the left (`...Ons\FuBar_DakSmak\Libs\…`).
let mut rows: Vec<(String, Vec<String>)> =
names.iter().map(|n| (n.clone(), Vec::new())).collect();
// The LOAD half first, already attributed by whose manifest was being walked — the survey's
// own `loaded` rule applies unchanged: a named file the package does not contain is not a
// raise (2155), so it is not counted here either.
// Load failures first, attributed by whose manifest was walked; a missing named file is not a
// raise, as in the survey.
for (folder, errs) in load_errors {
let Some(row) = rows.iter_mut().find(|r| r.0 == folder) else {
continue;
@@ -216,8 +172,8 @@ fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
rows
}
/// A load failure that is only *a file the package does not contain* — the reference logs
/// `Couldn't open %s` and carries on (2155), so it is not a raise here either.
/// A load failure that is only a file the package does not contain: the reference logs
/// `Couldn't open %s` and carries on.
fn is_absent_file(err: &str) -> bool {
err.ends_with(": not found")
|| err.contains("no provider hit for")
@@ -228,11 +184,7 @@ fn is_absent_file(err: &str) -> bool {
mod tests {
use super::*;
/// **The control answers the question it exists for**: an addon whose package is short a
/// library its neighbour ships fails ALONE and is clean TOGETHER.
///
/// Both halves are asserted, because either one alone would pass for the wrong reason — a
/// control that finds everything clean is worthless, and so is one that finds nothing.
/// An addon short a library its neighbour ships fails alone and is clean together.
#[test]
fn a_library_a_neighbour_ships_is_there_in_the_shared_vm() {
let tmp =
@@ -244,16 +196,15 @@ mod tests {
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
std::fs::write(dir.join(file), body).unwrap();
};
// Loads FIRST (`A` before `Z` under the walk's NTFS collation) and puts the library in
// the one global state, exactly as any of the seventy corpus packages that ship a copy of
// `Tablet-2.0` does.
// Loads first (`A` before `Z` under the walk's NTFS collation) and puts the library in the
// global state, as corpus packages that ship `Tablet-2.0` do.
write(
"AlphaShipsIt",
"## Interface: 11200\nlib.lua\n",
"lib.lua",
"SharedLibrary = { greet = function() return 1 end }\n",
);
// Declares no dependency on it and does not ship it — the corpus's own shape.
// Declares no dependency on it and does not ship it.
write(
"ZuluWantsIt",
"## Interface: 11200\nuse.lua\n",
@@ -287,7 +238,7 @@ mod tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// ...and a raise is still ATTRIBUTED, rather than the shared VM turning everything green.
/// A raise in the shared VM is still attributed to its folder.
#[test]
fn a_raise_in_the_shared_vm_lands_on_the_folder_that_raised() {
let tmp = std::env::temp_dir().join(format!(
+45 -160
View File
@@ -1,63 +1,17 @@
//! **The use column — "does it survive being USED?"**
//! The use column: drive real pointer input (hover, left and right click, drag) at the frames the
//! addon itself painted, through [`UiScript::mouse_move`] and [`UiScript::mouse_button`] as the
//! app does, and report what raised. An addon can load, run its handlers and draw, yet raise the
//! moment it is hovered.
//!
//! ## Why there is a fifth column, and why it is the fourth of its kind
//! A row that drove nothing is [`Used::Untouched`], never a pass; [`UseReport::driven`] prints
//! beside every verdict.
//!
//! This survey has now been wrong at four successive depths, and a human found every one of them by
//! **playing**, never a number here:
//! Targets come from [`super::render`]'s attribution, hit-tested, and a target is kept only when
//! the frame that captures the point is one the addon created: driving a pre-existing frame would
//! charge its raise to whichever addon is in the VM. So an addon that only hooks existing frames
//! (`PlayerFrame:SetScript("OnEnter", …)`) reads `untouched` here.
//!
//! 1. `loaded` measured *"the addon's files ran"*. Bagnon scored perfect and did nothing.
//! 2. [`super::drive_session_start`] measured *"the handlers ran"* (1213). Same result.
//! 3. [`super::drive_ui_probe`] measured *"the override body ran"*. Same result.
//! 4. [`super::render`] measured *"something was drawn"* (1230). Bagnon now draws sixteen bag
//! slots — and the director hovered them and got a wall of
//! `attempt to call global 'ContainerFrameItemButton_OnEnter' (a nil value)`.
//!
//! Every one of those columns asks whether something **ran or appeared**. None of them **touches**
//! anything. So an addon whose UI is fully drawn and completely inert scores full marks on all
//! four, which is exactly what Bagnon did on the morning this was written.
//!
//! This column drives real pointer input — hover, left click, right click, drag — at the frames the
//! addon **itself painted**, through the same [`UiScript::mouse_move`]/[`UiScript::mouse_button`]
//! entry points the app feeds from the host window, and reports what raised.
//!
//! ## "Nothing raised" and "nothing was touched" are different answers
//!
//! [`Used::Untouched`] exists because the failure this instrument is most likely to repeat is its
//! own history: the UI probe once shipped with a `pcall` that swallowed every raise and reported a
//! spotless corpus forever. A probe that drove **zero** targets and printed "clean" would be that
//! bug with a different mechanism, so a row that touched nothing says so in the verdict itself and
//! is counted separately in the report. [`UseReport::driven`] is printed beside every verdict for
//! the same reason: a clean row with `driven = 0` is not evidence of anything.
//!
//! `!OmniCC` — director-verified working — is precisely that shape, and it is the reason the
//! distinction is not academic. Its entire visible output is a `FontString` on an **anonymous,
//! mouse-disabled** `Frame` parented to a cooldown; there is nothing on it a pointer can reach, and
//! there is nothing wrong with it either. `untouched`, not `raised`.
//!
//! ## What gets driven: the addon's own frames, and only those
//!
//! The target list is [`super::render`]'s own attribution — the quads that addon painted — turned
//! into points and hit-tested. **A candidate is kept only when the frame that captures the point is
//! one the addon created**; when one of ours is on top, or the painted region belongs to a
//! mouse-disabled frame and our window underneath eats the click, the candidate is dropped rather
//! than driven.
//!
//! That rule is the whole attribution argument. Driving one of *our* frames and charging what it
//! raises to whichever addon happens to be in the VM is the mis-attribution decision 1209 was
//! written about, and it would smear a single FrameXML bug across two hundred rows.
//!
//! The cost of the rule, stated: **an addon that only hooks our frames is invisible here.** A
//! `PlayerFrame:SetScript("OnEnter", …)` replacement is a real corpus shape, and this column scores
//! it `untouched`. Reaching it needs a script-table diff rather than a handle diff — a different
//! instrument, named here so nobody reads an `untouched` row as proof of anything.
//!
//! ## What a raise here means, and what it does not
//!
//! It means **a player doing that would see that error**. It does *not* say whose fault it is: the
//! wall the director hit is our missing `ContainerFrameItemButton_OnEnter`, reached through
//! Bagnon's slot button. That is the same open question 1213 §4 and 1204 §4 left about every other
//! column, and it is deliberately not answered here — "hovering this addon's UI raises" is the
//! finding, whoever owes the fix.
//! A raise means a player doing that would see the error; it does not say whose code is at fault.
use std::collections::HashSet;
@@ -66,45 +20,25 @@ use benilla_ui::widget::FrameHandle;
use super::render::{Painted, RenderBaseline};
/// How many distinct frames one addon gets driven. **The bound, and what it drops.**
///
/// 218 VMs times this times the seven pointer events below is the whole cost of the column, and
/// each event pays a full [`UiScript::hit_test`] (a traversal + sort of the arena). Eight is what a
/// window's worth of interactive surface looks like — Bagnon's sixteen slots are sixteen instances
/// of one button template, so the ninth tells you nothing the first did not — and it holds the
/// column's share of the survey to seconds rather than minutes.
///
/// What it drops: the 9th..Nth distinct target, in painter order (so the earliest-drawn survive).
/// An addon whose only broken widget is the twelfth thing it painted reads clean here. Stated
/// rather than hidden, and the number is a constant precisely so raising it is one edit.
/// How many distinct frames one addon gets driven; each event pays a full hit-test. Targets past
/// it, in painter order, are dropped, so a broken ninth widget reads clean.
pub const MAX_USE_TARGETS: usize = 8;
/// How far the drag gesture travels. Must exceed `cursor::DRAG_START_THRESHOLD` (4 px, strict `>`)
/// or `OnDragStart` never fires and the "drag" is silently two clicks; small enough that it stays
/// inside a 16-px item slot, or the release lands on a different frame and `OnReceiveDrag` goes
/// somewhere else.
/// How far the drag travels: past `cursor::DRAG_START_THRESHOLD` (4 px, strict `>`) so
/// `OnDragStart` fires, and inside a 16 px item slot so the release lands on the same frame.
const DRAG_PX: f32 = 5.0;
/// Where the cursor is parked at the end, to fire the last `OnLeave`.
///
/// Off-screen by a mile: [`UiScript::hit_test`] answers `None` there whatever the addon built, so
/// the final hover boundary is guaranteed to be crossed. A hover half-driven — `OnEnter` fired and
/// `OnLeave` never — is the shape that leaves a tooltip up forever in play, and it is a real corpus
/// bug class, so the probe always closes the pair.
/// Where the cursor is parked at the end, off-screen so the last `OnLeave` always fires.
const PARKED: (f32, f32) = (-10_000.0, -10_000.0);
/// What happened when the addon's own UI was used.
///
/// Deliberately **not** ordered: there is no honest ranking between "it raised" and "there was
/// nothing to touch", and deriving one would invite a `>` comparison that quietly asserts it.
/// What happened when the addon's own UI was used; unordered, since "raised" and "nothing to
/// touch" do not rank.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Used {
/// **Nothing was touched** — the addon painted nothing a pointer can reach, so this row is not
/// a pass and not a failure. The verdict exists so a probe that drove zero targets can never
/// be read as one that drove some and found nothing (see the module doc).
/// The addon painted nothing a pointer can reach: neither a pass nor a failure.
#[default]
Untouched,
/// Driven, and it raised. The director's Bagnon: sixteen slots on screen, dead to the touch.
/// Driven, and it raised.
Raised,
/// Driven, and nothing raised.
Survived,
@@ -124,15 +58,12 @@ impl Used {
/// What one addon's UI did when it was used.
#[derive(Debug, Clone, Default)]
pub struct UseReport {
/// Frames actually driven — **the number that makes a clean row mean anything**. Zero here and
/// an empty [`Self::errors`] is [`Used::Untouched`], not a pass.
/// Frames actually driven; zero is [`Used::Untouched`], not a pass.
pub driven: usize,
/// Distinct frames of the addon's own that answered a hit-test, **before** [`MAX_USE_TARGETS`].
/// Greater than [`Self::driven`] means the cap dropped some; printed so the bound is visible in
/// the data rather than only in this comment.
/// Distinct addon frames that answered a hit-test, before [`MAX_USE_TARGETS`]; more than
/// [`Self::driven`] means the cap dropped some.
pub touchable: usize,
/// The names of the frames driven (anonymous ones omitted), so a row can be read back to a
/// place in the addon's own code. Capped by [`MAX_USE_TARGETS`] already.
/// The names of the frames driven, anonymous ones omitted.
pub frames: Vec<String>,
/// Errors raised by the input, verbatim, in the order they happened.
pub errors: Vec<String>,
@@ -150,42 +81,25 @@ impl UseReport {
/// Drive hover, both clicks and a drag at everything the addon drew, and report what raised.
///
/// **Runs after every other column has been read and before the method oracle**, for the two
/// reasons 1225 and 1230 already established: after, so no number beside it can be perturbed by
/// input this probe invented; before, because the oracle stands up one widget of every kind and
/// those sixteen frames of the harness's own must never become an addon's input targets.
///
/// `painted` is [`super::render`]'s attribution, not a fresh walk — see [`Painted`].
/// Runs after every other column is read, so its input perturbs none of them, and before the
/// method oracle, whose widgets must never become input targets. `painted` is
/// [`super::render`]'s attribution, not a fresh walk.
pub(super) fn measure_use(
script: &mut UiScript,
baseline: &RenderBaseline,
painted: &[Painted],
) -> UseReport {
let mut report = UseReport::default();
// PUT THE TOOLTIP AWAY FIRST, before a single hit-test.
//
// `measure_render` deliberately leaves `GameTooltip` **shown** — it is measuring what is on
// screen at the end, and a tooltip is on screen. But it is one of OURS, it lives in the
// TOOLTIP strata (above everything), and it is therefore topmost over whatever it covers: any
// addon frame underneath would hit-test to the tooltip, be dropped by the attribution rule
// below as "one of ours", and leave the addon reading `untouched` for a reason that has
// nothing to do with the addon. That is this column's own founding failure — a probe that
// silently drove nothing and reported clean — so it is closed here rather than left to luck.
//
// Hiding it is also the faithful state: in play the tooltip trails the cursor, it is never the
// thing under it. Done BEFORE the error baseline below, so an `OnHide` hook's raise is not
// charged to input this probe has not driven yet.
// Hide the tooltip `measure_render` left shown first: in the TOOLTIP strata it would capture
// every hit-test beneath it, as in play it never does. Done before the error baseline, so an
// `OnHide` hook's raise is not charged to input.
let _ = script.run(
r#"
if GameTooltip and type(GameTooltip.Hide) == "function" then pcall(function() GameTooltip:Hide() end) end
"#,
);
// The addon's painted quads, resolved to the frames a cursor over them would actually hit.
//
// Two dedupes, and they answer different questions. `owners` bounds the WORK: an icon, its
// border, its count text and its highlight are four quads on one button, and hit-testing each
// is four traversals for one answer. `hits` bounds the TARGETS: two different buttons can
// stack, and driving the same captured frame twice would double-count a raise.
// Painted quads resolved to the frames a cursor over them would hit. `owners` dedupes the
// work (many quads on one button), `hits` the targets (a frame driven twice double-counts).
script.resolve();
let mut owners: HashSet<FrameHandle> = HashSet::new();
let mut hits: HashSet<FrameHandle> = HashSet::new();
@@ -197,9 +111,7 @@ pub(super) fn measure_use(
let Some(hit) = script.hit_test_frame(p.point.0, p.point.1) else {
continue; // painted, but nothing there takes the mouse
};
// THE ATTRIBUTION RULE. One of ours captured the point — our window on top, or the
// addon's own region sitting on a mouse-disabled frame with ours underneath. Driving it
// would charge our handler's raise to this addon, in all 218 VMs (1209).
// A pre-existing frame captured the point: its raise is not this addon's.
if baseline.is_pre_existing(hit) {
continue;
}
@@ -216,64 +128,37 @@ pub(super) fn measure_use(
.filter_map(|(fh, _)| script.frame_name(*fh))
.collect();
// The engine collects handler raises itself (`UiScript::push_error`), so there is nothing to
// swallow here and nothing to remember to re-read: a `split_off` of the tail is the whole
// capture. This is deliberately NOT the `pcall`-and-collect shape `drive_ui_probe` needs —
// that one calls Lua globals directly, this one goes through the real input path.
// The engine collects handler raises itself (`UiScript::push_error`); the tail is the capture.
let before = script.errors().len();
for (_, (x, y)) in &targets {
drive_one(script, *x, *y);
}
// Close the hover pair on the last target (see [`PARKED`]).
// Close the hover pair on the last target.
script.mouse_move(PARKED.0, PARKED.1);
report.errors = script.errors().split_off(before);
report
}
/// One target, four gestures: **hover · left click · right click · drag**.
/// One target, four gestures through the real input path: hover, left click, right click, drag.
///
/// Every one of them is what it says. `mouse_move` fires the real `OnEnter`/`OnLeave` pair on a
/// hover-boundary crossing; `mouse_button` runs the real press/release path — `OnMouseDown`,
/// `OnMouseUp`, the `RegisterForClicks` adjudication that decides whether `OnClick` fires at all
/// and with which button, the double-click detector, and the drag trio. None of it is simulated:
/// this is the same code the host window's events reach.
///
/// A [`UiScript::resolve`] between gestures because a handler is free to move, show or build
/// things, and the next hit-test must see what it did. It is tier-1 gated (nothing touched the
/// layout ⇒ immediate return), so the quiet case is free.
///
/// **The drag is honest, with one stated degradation.** On a frame that called `RegisterForDrag`
/// the press → move → release below is the real gesture: `OnDragStart` past the 4-px threshold,
/// then `OnDragStop` on the source and `OnReceiveDrag` on whatever the release hit. On a frame
/// that did not, the same three events are simply a second click — and because the probe does not
/// tick, that second click lands inside the 300-ms window and fires `OnDoubleClick` where the
/// widget has one. Both are inputs a player produces; neither is invented.
///
/// **Two of these four lines overlap, and the falsification run found it rather than the design —
/// so it is written down here before somebody "simplifies" one of them away.** Deleting the hover
/// move alone does *not* stop `OnEnter` firing (the drag's move crosses the same boundary), and
/// deleting the left click alone does *not* stop `OnClick` firing (the degenerate drag above *is*
/// a left click). Only removing **every** cursor move, or **every** left-button transition, turns
/// the matching fixture red — which is what `use_probe_tests` actually pins. The overlap is not
/// waste: the ORDER is the player's (you hover a thing before you click it), and a handler that
/// only breaks on the second click is a real bug class.
/// A [`UiScript::resolve`] follows each, since a handler may move, show or build frames. On a
/// frame without `RegisterForDrag` the drag is a second click inside the 300 ms window, so it
/// fires `OnDoubleClick`. The gestures overlap (the drag's move also hovers, its press also
/// clicks); the order is a player's, hover before click, and the tests pin the removal of every
/// move or every left-button transition.
fn drive_one(script: &mut UiScript, x: f32, y: f32) {
// HOVER. The single most common thing a player does to a bag slot, and the gesture the
// director's error wall came from.
// Hover.
script.mouse_move(x, y);
script.resolve();
// LEFT CLICK — press and release on the same frame, which is what `wants_click` requires
// before `OnClick` fires at all.
// Left click: press and release on the same frame, as `wants_click` requires.
script.mouse_button(x, y, "LeftButton", true);
script.mouse_button(x, y, "LeftButton", false);
script.resolve();
// RIGHT CLICK. Not a duplicate of the left one: the default registration is
// `{"LeftButtonUp"}`, so a right click reaches `OnClick` only on a widget that asked for it —
// which every container slot in the game does, because right-click is how you use an item.
// Right click: reaches `OnClick` only where the widget registered it, as container slots do.
script.mouse_button(x, y, "RightButton", true);
script.mouse_button(x, y, "RightButton", false);
script.resolve();
// DRAG.
// Drag.
script.mouse_button(x, y, "LeftButton", true);
script.mouse_move(x + DRAG_PX, y);
script.mouse_button(x + DRAG_PX, y, "LeftButton", false);
@@ -1,54 +1,15 @@
//! **The use column's can-it-fail proof, in both directions and per gesture.**
//!
//! This arc has shipped two instruments that could not fail — the UI probe swallowed every raise it
//! was built to report, and the method oracle answered "nothing missing" when it could not run at
//! all — so a new column arrives with its falsifier or it does not arrive.
//!
//! Two directions, and both are load-bearing:
//!
//! - **It must report.** Four fixtures, one per gesture, each drawing one button and raising from
//! exactly one handler: `OnEnter`, `OnClick` on the left, `OnClick` on the right (registered for
//! `RightButtonUp` only, so a left click cannot reach it), and `OnDragStart`. A column that
//! drove only clicks passes three of these and fails one, which is the point of splitting them.
//! - **It must stay quiet.** A fixture that draws the *same* button with **no handlers at all**
//! must come out [`Used::Survived`] with `driven ≥ 1` and zero errors — the shape that proves the
//! errors above came from the addons and not from the probe.
//!
//! ...and one more, which is the distinction the whole column is built around: a fixture that
//! **paints but takes no mouse** must come out [`Used::Untouched`] with `driven == 0`, never
//! `Survived`. "Nothing raised" and "nothing was touched" are different answers, and an instrument
//! that cannot tell them apart is the one that reported a spotless corpus for a month.
//!
//! Then the real oracle: the director's two verified addons. `!OmniCC` works and must not be
//! reported broken; Bagnon's slots are the reason this file exists and must at minimum be
//! **reachable** — whether they still raise is the parallel handler fix's to change, so the
//! durable assertion is that the probe can get its hands on them at all.
//!
//! ## What the falsification run actually established (2026-08-11)
//!
//! Each was applied to `use_probe`, run, and reverted — a can-it-fail claim is worth what it was
//! measured at, not what it was asserted at:
//!
//! | change to the probe | this file |
//! |---|---|
//! | right-click press/release removed | RED — `RaisesOnRightClick` reads `Survived`, `driven=1` |
//! | drag presses and releases in place (no 4-px move) | RED — `RaisesOnDrag` |
//! | every `mouse_move` removed | RED — `RaisesOnEnter` |
//! | every left-button transition removed | RED — `RaisesOnLeftClick` |
//! | `Untouched` folded into `Survived` | RED — `PaintsButTakesNoMouse` |
//!
//! And the two **negative** results, which are the useful ones: removing only the hover move
//! leaves this file GREEN (the drag's own move crosses the same hover boundary), and removing only
//! the explicit left click leaves it GREEN (the drag press/release *is* a left click on a frame
//! that never registered for drag). So what these fixtures pin is the gesture, not the line —
//! stated in `drive_one` too, where somebody would otherwise delete an "obviously redundant" call.
//! Tests for the use column: one fixture per gesture that raises from exactly one handler, a
//! silent button that must survive with `driven >= 1`, a painted mouse-disabled frame that must be
//! `untouched`, and two real corpus addons that must be reachable. The fixtures pin each gesture,
//! not each line: removing only the hover move or only the explicit left click stays green, since
//! the drag also hovers and clicks.
use std::path::{Path, PathBuf};
use super::{survey, Used};
/// One throwaway AddOns root, cleaned up on drop even if a test panics. (The twin of
/// `render_tests::Fixtures`; kept local so the two files' fixtures cannot collide in `temp_dir`.)
/// One throwaway AddOns root, cleaned up on drop even if a test panics; its own temp prefix keeps
/// it apart from `render_tests`'.
struct Fixtures(PathBuf);
impl Fixtures {
@@ -88,11 +49,8 @@ impl Drop for Fixtures {
}
/// A 64×64 painted Button at the centre of the screen, plus whatever `extra` wires onto it.
///
/// Painted, because an unpainted frame never reaches the target list at all — the use column takes
/// its aim from the render column's attribution, so every fixture here has to draw first. Centred,
/// because the corners of the default UI are full of our own windows and a fixture underneath one
/// of them would be dropped by the attribution rule for a reason the test is not about.
/// Painted, since the use column aims at the render column's quads; centred, clear of the default
/// UI's windows.
fn painted_button(extra: &str) -> String {
format!(
r#"
@@ -108,12 +66,11 @@ fn painted_button(extra: &str) -> String {
)
}
/// **The proof: every gesture is really driven, and silence is really silence.**
/// Every gesture is driven, silence stays silent, and nothing touched is not a pass.
#[test]
fn the_use_column_can_fail() {
let fx = Fixtures::new("cannotfail");
// One fixture per gesture. Each raises from exactly ONE handler, so a probe that skips that
// gesture reports this addon clean and the assertion below catches it by name.
// One fixture per gesture, each raising from exactly one handler.
fx.addon(
"RaisesOnEnter",
&painted_button(r#"f:SetScript("OnEnter", function() error("USEFIXTURE_ENTER") end)"#),
@@ -122,10 +79,8 @@ fn the_use_column_can_fail() {
"RaisesOnLeftClick",
&painted_button(r#"f:SetScript("OnClick", function() error("USEFIXTURE_LEFTCLICK") end)"#),
);
// Registered for the RIGHT button ONLY — the default set is `{"LeftButtonUp"}`, so this
// handler is unreachable by any amount of left-clicking. It is the fixture that separates
// "the probe clicks" from "the probe clicks with both buttons", which matters because
// right-click is how every container slot in the game is used.
// Registered for `RightButtonUp` only (the default is `{"LeftButtonUp"}`), so no left click
// reaches it.
fx.addon(
"RaisesOnRightClick",
&painted_button(
@@ -135,8 +90,7 @@ fn the_use_column_can_fail() {
"#,
),
);
// `OnDragStart` fires only past the 4-px threshold, so this one also proves the probe's drag
// actually MOVES rather than pressing and releasing in place.
// `OnDragStart` fires only past the 4 px threshold, so the drag must actually move.
fx.addon(
"RaisesOnDrag",
&painted_button(
@@ -146,12 +100,9 @@ fn the_use_column_can_fail() {
"#,
),
);
// THE OTHER DIRECTION. The same painted, mouse-taking button with nothing wired to it at all:
// driven, and silent. Without this row the four above prove only that the probe can produce
// errors, not that the errors came from the addons.
// The same button with no handlers: driven and silent, so the errors above are the addons'.
fx.addon("SilentButTouchable", &painted_button(""));
// ...and the distinction the column exists for. It paints exactly as much as the others and
// takes no mouse, so nothing of the addon's answers a hit-test: `untouched`, never `ok`.
// Paints but takes no mouse: `untouched`, never `ok`.
fx.addon(
"PaintsButTakesNoMouse",
r#"
@@ -174,8 +125,7 @@ fn the_use_column_can_fail() {
.unwrap_or_else(|| panic!("{name} was not surveyed"))
};
// Every fixture must have LOADED and DRAWN, or a `driven = 0` below would be measuring a load
// failure or a blank window rather than the thing under test.
// Each fixture must load and draw, or `driven = 0` would measure something else.
for name in [
"RaisesOnEnter",
"RaisesOnLeftClick",
@@ -197,7 +147,7 @@ fn the_use_column_can_fail() {
);
}
// ── It reports, per gesture ────────────────────────────────────────────────────────────────
// ── It reports, per gesture ──
for (name, marker) in [
("RaisesOnEnter", "USEFIXTURE_ENTER"),
("RaisesOnLeftClick", "USEFIXTURE_LEFTCLICK"),
@@ -220,7 +170,7 @@ fn the_use_column_can_fail() {
);
}
// ── ...and it stays quiet when there is nothing to report ──────────────────────────────────
// ── It stays quiet when nothing raises ──
let quiet = row("SilentButTouchable");
assert_eq!(
quiet.used.verdict(),
@@ -234,7 +184,7 @@ fn the_use_column_can_fail() {
whole column is about"
);
// ── ...and it never calls "nothing was touched" a pass ─────────────────────────────────────
// ── Nothing touched is not a pass ──
let untouched = row("PaintsButTakesNoMouse");
assert_eq!(
untouched.used.verdict(),
@@ -248,22 +198,13 @@ fn the_use_column_can_fail() {
);
}
/// **The real oracle: the director's two verified addons.**
///
/// - `!OmniCC` **works** — its countdown numbers are on their screen. It must not be reported as
/// broken. It is also the reference [`Used::Untouched`]: its whole visible output is a
/// `FontString` on an anonymous, mouse-disabled `Frame`, so there is nothing on it to click and
/// nothing wrong with it either. If this column ever calls it `raised`, the column is wrong.
/// - **Bagnon** is why this file exists: sixteen bag slots drawn and dead to the touch. The
/// assertion here is the durable half — the probe can **reach** them (`driven ≥ 1`, on a
/// `BagnonItem*` frame). Whether they still raise is the parallel handler fix's to change, so
/// asserting `Raised` would turn somebody else's fix into this file's failure; what must never
/// regress is the probe's ability to get its hands on the slots at all, because a column that
/// silently stops touching them is exactly how this instrument has been wrong four times.
/// `!OmniCC` is never `raised`: its output is a `FontString` on an anonymous mouse-disabled frame,
/// so it is `untouched`. Bagnon's item slots must be reachable (`driven >= 1` on a `BagnonItem*`
/// frame); whether they raise is not asserted here.
#[test]
fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() {
fn bagnon_is_reachable_and_omnicc_is_not_broken() {
benilla_formats::wow_data_or_skip!();
// The one resolver, and a skip the gate can refuse (`benilla_formats::install`).
// 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"] {
@@ -282,7 +223,7 @@ fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() {
assert_ne!(
omni.used.verdict(),
Used::Raised,
"!OmniCC is on the director's screen and works; a column that calls it broken is broken \
"!OmniCC draws in the live client and works; a column that calls it broken is broken \
itself: {:?}",
omni.used.errors
);
@@ -290,7 +231,7 @@ fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() {
let bagnon = row("Bagnon");
assert!(
bagnon.used.driven >= 1,
"Bagnon draws bag slots the director can put a cursor on; the probe must be able to reach \
"Bagnon draws bag slots a player can put a cursor on; the probe must be able to reach \
at least one (drew={:?} touchable={})",
bagnon.render.frames,
bagnon.used.touchable
@@ -301,7 +242,7 @@ fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() {
.frames
.iter()
.any(|f| f.starts_with("BagnonItem")),
"…and the thing it touched must be a SLOT — the exact widget the director hovered: {:?}",
"…and the thing it touched must be a SLOT — the exact widget a player hovers: {:?}",
bagnon.used.frames
);
}
+60 -135
View File
@@ -1,62 +1,33 @@
//! The player's area identity, published to the UI — the real 1.12 `ZONE_CHANGED` event family
//! and the zone-text host globals behind `GetZoneText`/`GetSubZoneText`/`GetRealZoneText`/
//! `GetMinimapZoneText`/`GetZonePVPInfo` (decision 0287; corrected to the bytes by its fold-back
//! record).
//! The player's area identity for the UI: the `ZONE_CHANGED` event family and the host globals
//! behind `GetZoneText`, `GetSubZoneText`, `GetRealZoneText`, `GetMinimapZoneText` and
//! `GetZonePVPInfo`, all fired from the one updater `0x494780`.
//!
//! The model (`0x494780` is the one updater all three zone events fire from, on the per-update
//! area resolve):
//! - A zone id change fires `ZONE_CHANGED_NEW_AREA` alone; else a text change fires
//! `ZONE_CHANGED_INDOORS` or `ZONE_CHANGED` by the indoor bit. The first world-enter is a zone
//! id change from the zeroed cache.
//! - The zone is the leaf's single-hop parent (AreaTable field 2, itself when 0).
//! - The indoor bit is the faces-only down-ray (`0x6a87f0`): the surface below is a WMO face whose
//! group lacks MOGP `0x8` EXTERIOR.
//! - Indoor naming (`0x67e670`): while indoors in the same group the whole updater is skipped
//! (`[0x868608]`, `[0x86860c]`). On a change, the whole-WMO row (`0x69d830`) overrides the zone
//! slot and nulls the subzone when its name is non-empty and differs from the subzone; an
//! unnamed row resolves through its `AreaTableID`, a missing row never overrides. Then the
//! group's own row (`0x69d8f0`) refills the subzone. Both are exact-key lookups.
//! - `GetRealZoneText` reads the zone name before the indoor override.
//! - `GetMinimapZoneText` is subzone-else-zone (`0xb4da28`), with its own `MINIMAP_ZONE_CHANGED`
//! (`0x494970`).
//!
//! - The client caches `(zoneId, zoneText, subzoneText)` and compares per update: **zone id
//! changed → `ZONE_CHANGED_NEW_AREA` fires alone**; else **either text changed →
//! `ZONE_CHANGED_INDOORS`** when the resolver's indoor bit is set, else **`ZONE_CHANGED`**.
//! Mutually exclusive; the first world-enter is a zone-id change from the zeroed cache.
//! - **Indoors, the zone-text slot takes the WMO interior's name and the subzone slot nulls**
//! (`0x67e670`) — entering a named inn splashes the inn's name. `GetRealZoneText` reads the
//! pre-override (WMO-immune) zone name; `GetMinimapZoneText` reads subzone-else-zone
//! (`0xb4da28`, its own change event `MINIMAP_ZONE_CHANGED` from `0x494970`).
//! - The "zone" is the leaf's **single-hop parent** (AreaTable field 2; itself when 0) — equal
//! to the defensive `top_zone` walk on 5875 data (chains are 1–2 deep), which the world map
//! keeps using.
//! - `GetZonePVPInfo` (`0x48d540`): `isArena` = the **leaf** row's Flags bit `0x80` (the FFA-pit
//! flag); ownership = the **zone** row's FactionGroupMask vs the player template's
//! friend-then-enemy group masks → "friendly"/"hostile", else **"contested" — never nil for an
//! 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`] — 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
//! (the inn splash; skipped at the abbey, whose default name equals its yard subzone) —
//! nulling the subzone; an existing-but-unnamed −1 row resolves through its `AreaTableID`'s
//! AreaTable name (Ironforge's unnamed city WMO), a missing row never overrides. Then
//! **query B** (the hit group's own row, exact key, `0x69d8f0`) re-populates the subzone
//! ("Main Hall"). No name-set retry, no cross-row fallback anywhere.
//! On the first world-enter `0x494780` also sets the world map to the player's zone
//! (`0x4a6650`); that half lives in `crate::ui_world_map::world_enter_selection`.
//!
//! **`0x494780` has one more effect than the election, and it does NOT live here** (2192): when
//! the cached zone id was 0 — the first world-enter of a session — it also calls `0x4a6650`, the
//! player→(continent, zone) resolver, and hands the answer to the world map's `SetMap` setter, so
//! a freshly-logged-in client is already on the player's own zone map before any Lua asks. That
//! half is modelled where the map data lives, in
//! [`crate::ui_world_map::world_enter_selection`] — the gate there is the same precondition
//! (`0x67e510` never reaches this updater with a zero zone id), read off our own area feed rather
//! than off the cache below.
//! Host globals are written before the event fires, so a handler's `GetZoneText()` sees the new
//! state.
//!
//! The area *authority* stays `terrain_stream::CurrentArea` (decision 0232 — the MCNK `areaId`
//! with the WMO-interior override, now off the same faces-only claim). Host globals are written
//! before the event fires, so a handler's `GetZoneText()` already sees the new state.
//! Deviation: the indoor dedup keys `(WMOID, MOGP uniqueID)`, not the client's raw per-WMO group
//! index, because the raw index can alias across adjacent buildings.
//!
//! Named divergence: the dedup keys `(WMOID, MOGP uniqueID)` where the client stores the raw
//! per-WMO group INDEX — ours changes exactly when the hit group does but never aliases across
//! adjacent buildings the way the raw index can. INTERIM residue: the `[0x88272c]` PvP display
//! gate (an unnamed cached boolean, verified NOT realm type) ships open.
//!
//! [`AreaTableRes`] is also the one shared `AreaTable.dbc` projection (0287's consolidation):
//! the world map and this module consume it. (`area_sound.rs` keeps its own audio-FK fold —
//! different columns, different concern.)
//! The reference's PvP display gate `[0x88272c]` (a cached boolean, not the realm type) is
//! untraced; ours is always open.
use bevy::ecs::system::NonSendMut;
use bevy::prelude::*;
@@ -68,12 +39,11 @@ use crate::net::{ObjectStore, SelfPlayer};
use crate::target::Factions;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
/// The shared `AreaTable.dbc` catalog: id → (name, parent zone, flags, faction mask). Loaded once
/// at Startup; absent if the DBC failed to read (consumers take `Option` and go quiet).
/// The shared `AreaTable.dbc` catalog, read by the world map and this module; absent if the DBC
/// failed to read.
#[derive(Resource)]
pub(crate) struct AreaTableRes(pub(crate) AreaTableCatalog);
/// Startup: load the shared area catalog off the patch chain.
fn load_area_table(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
@@ -89,32 +59,24 @@ fn load_area_table(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
}
}
/// What the client's zone-text cache holds — the compare targets of `0x494780` (zone id + the
/// two display strings), the minimap line's own cache (`0x494970`), and the indoor dedup pair
/// (`[0x868608]` prev-indoor / `[0x86860c]` prev WMO-area row id).
///
/// Held as a [`crate::ui_script::VmMemo`], which is not a deviation from that BSS model but the
/// completion of it: the cache's whole purpose is deduping what was last *pushed into the VM*, and
/// the VM is built at world entry and destroyed at the character screen (decision 1290). A fresh VM
/// is the client's own fresh BSS — the zeroed cache below is exactly the state the real client is in
/// at that moment, so the first resolve of every login is a NEW_AREA with the globals written, which
/// is what the zone splash and the minimap line need.
/// The client's zone-text cache: the compare targets of `0x494780`, the minimap line's cache
/// (`0x494970`) and the indoor dedup pair (`[0x868608]`, `[0x86860c]`). It lives per VM, so every
/// login starts zeroed and its first resolve is a NEW_AREA.
#[derive(Default)]
struct ZoneCache {
/// `None` = never resolved (the zeroed BSS cache: the first resolve is a NEW_AREA).
/// `None` until the first resolve.
zone_id: Option<u32>,
zone_text: String,
subzone_text: String,
minimap_text: String,
/// The previous update's indoor bit (`[0x868608]`).
/// `[0x868608]`.
indoor: bool,
/// 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.
/// The previous hit group (`[0x86860c]`, keyed as the module doc's deviation says).
wmo_id: u32,
wmo_group: u32,
}
/// The texts + indoor bit one resolve produces (the updater's compare inputs).
/// One resolve's compare inputs.
struct ZoneSignal {
zone_id: u32,
zone_text: String,
@@ -122,8 +84,7 @@ struct ZoneSignal {
indoor: bool,
}
/// The event election, verbatim from `0x494780`: zone id changed → NEW_AREA **alone**; else any
/// text changed → INDOORS/CHANGED by the indoor bit; else nothing.
/// The event election of `0x494780`.
fn elect_event(cache: &ZoneCache, next: &ZoneSignal) -> Option<&'static str> {
if cache.zone_id != Some(next.zone_id) {
return Some("ZONE_CHANGED_NEW_AREA");
@@ -138,10 +99,8 @@ fn elect_event(cache: &ZoneCache, next: &ZoneSignal) -> Option<&'static str> {
None
}
/// The zone-splash data plane (module doc). Per resolve: compute the display texts (with the
/// indoor override), the PvP tuple, write the host globals, then fire the elected zone event
/// and — independently, like the client's second site — `MINIMAP_ZONE_CHANGED` when the
/// subzone-else-zone line changed.
/// Resolves the texts and PvP info, writes the host globals, then fires the elected zone event and,
/// independently, `MINIMAP_ZONE_CHANGED`.
fn feed_zone_events(
script: Option<NonSendMut<UiScript>>,
world: benilla_world::world_point::WorldPoint,
@@ -156,12 +115,9 @@ fn feed_zone_events(
let cache = cache.get(&script);
let Some(leaf) = world.area() else { return };
let Some(leaf_row) = areas.0.get(leaf) else {
// An id the catalog doesn't know stays unpublished (keep the last real state, like the
// resolver itself does for areaId 0).
// An unknown id keeps the last state, as the resolver does for areaId 0.
return;
};
// The zone: the leaf's single-hop parent (field 2), itself when 0 (`0x494780`'s stored row;
// == `top_zone` on 5875's 1–2-deep chains).
let zone = if leaf_row.zone_id == 0 {
leaf
} else {
@@ -170,7 +126,7 @@ fn feed_zone_events(
let real_zone_text = areas.0.name(zone).unwrap_or_default().to_string();
let indoor = world.area_interior().is_some();
// The outdoor texts — the locals `0x67e670` starts from.
// The outdoor texts `0x67e670` starts from.
let mut zone_text = real_zone_text.clone();
let mut subzone_text = if leaf == zone {
String::new()
@@ -178,30 +134,19 @@ fn feed_zone_events(
leaf_row.name.clone()
};
// 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). 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.
// The client keys `[groupRec+0x7c]`, nonzero-gated; see the module doc's deviation.
wmo_group = k.group_area_id;
if cache.indoor
&& k.group_area_id != 0
&& (cache.wmo_id, cache.wmo_group) == (k.wmo_id, wmo_group)
{
return; // the dedup: same WMO group ⇒ the whole updater is skipped
return;
}
cache.wmo_id = k.wmo_id;
// Query A (the whole-WMO −1 row): override the zone name + null the subzone — only when
// the resolved name is non-empty and differs from the current subzone (the abbey skip).
// An EXISTING-but-unnamed row falls back to an AreaTable name — the row's own
// `AreaTableID`, else the current leaf (how Ironforge's unnamed city WMO splashes
// "Ironforge"; the client's area-0 arm reads the raw terrain areaId at the node — the
// current leaf equals it in that arm, since an area-0 row also never overrode the leaf).
// A MISSING row never overrides (the client's `""` sentinel bypass).
// The whole-WMO -1 row. An unnamed one with `AreaTableID` 0 takes the leaf's name, which
// is the terrain areaId the client's area-0 arm reads.
let a_name = default.as_ref().map(|d| {
if !d.name.is_empty() {
d.name.clone()
@@ -221,7 +166,7 @@ fn feed_zone_events(
subzone_text = String::new();
}
}
// Query B (the hit group's own row): re-populate the subzone (empty/missing = leave it).
// The hit group's own row refills the subzone.
if let Some(b) = group
.as_ref()
.map(|r| r.name.as_str())
@@ -236,7 +181,6 @@ fn feed_zone_events(
subzone_text,
indoor,
};
// GetMinimapZoneText's own line: subzone-else-zone (`0xb4da28`).
let minimap_text = if signal.subzone_text.is_empty() {
signal.zone_text.clone()
} else {
@@ -249,10 +193,10 @@ fn feed_zone_events(
return;
}
// GetZonePVPInfo (`0x48d540`): leaf row's 0x80 for isArena; zone row's mask vs the player
// template's friend-then-enemy masks; factionName from FactionGroup.dbc. Never "arena" as a
// type; "contested" (not nil) for an ownerless zone; nil only on structural failure. The
// `[0x88272c]` display gate ships open (module doc).
// GetZonePVPInfo (`0x48d540`): isArena is the leaf's Flags `0x80`; the type is the zone's
// FactionGroupMask against the player template's friend then enemy masks, "contested" when
// neither; never "arena"; nil only on structural failure. factionName is FactionGroup.dbc's
// Name for the zone's mask bit; the realm type never enters.
let is_arena = leaf_row.flags & 0x80 != 0;
let zone_mask = areas.0.get(zone).map_or(0, |r| r.faction_group_mask);
let pvp = factions
@@ -304,7 +248,7 @@ fn feed_zone_events(
cache.wmo_group = wmo_group;
}
/// The shared area catalog + the zone-event feed (decision 0287 + fold-back).
/// The shared area catalog and the zone-event feed.
pub(crate) struct AreaPlugin;
impl Plugin for AreaPlugin {
@@ -312,13 +256,9 @@ impl Plugin for AreaPlugin {
app.add_systems(Startup, load_area_table.after(AssetSet::Open))
.add_systems(
Update,
// After the script tick, like the other feeds: the event dispatches on the next
// tick — a frame of latency the splash can't perceive. MINIMAP_ZONE_CHANGED's
// handler reads the PvP globals written above in the same call. ALSO after the
// leaf authority (which orders after the interior claim): leaf, indoor bit, and
// names must come from ONE coherent frame — the client's single-pass resolve
// (`0x67e510`); a fresh claim against a stale leaf fired the abbey login's
// spurious big splash.
// After the leaf authority: leaf, indoor bit and names must come from one frame,
// as in the client's single-pass resolve (`0x67e510`), or a stale leaf fires a
// spurious splash.
feed_zone_events
.after(crate::ui_script::UiInput)
.after(benilla_world::terrain_stream::AreaAuthoritySet),
@@ -348,48 +288,41 @@ mod tests {
}
}
/// The `0x494780` election domain: first-enter and zone hops take NEW_AREA alone; same-zone
/// text changes split INDOORS/CHANGED on the indoor bit; no change fires nothing.
/// The `0x494780` election.
#[test]
fn election_matches_the_byte_law() {
// First world-enter: the zeroed cache is a zone-id change.
let c = ZoneCache::default();
assert_eq!(
elect_event(&c, &sig(12, "Elwynn Forest", "Northshire Valley", false)),
Some("ZONE_CHANGED_NEW_AREA")
);
// Zone hop: NEW_AREA alone — even though the texts changed too.
// Zone hop: NEW_AREA alone, though the texts changed too.
let c = cache(Some(12), "Elwynn Forest", "");
assert_eq!(
elect_event(&c, &sig(40, "Westfall", "The Jansen Stead", false)),
Some("ZONE_CHANGED_NEW_AREA")
);
// Outdoor subzone hop: same zone, subzone text changed → ZONE_CHANGED.
let c = cache(Some(12), "Elwynn Forest", "");
assert_eq!(
elect_event(&c, &sig(12, "Elwynn Forest", "Goldshire", false)),
Some("ZONE_CHANGED")
);
// Inn entry: same zone, the ZONE text changed by the indoor override, indoor set →
// INDOORS (the named-interior splash).
// Inn entry: the indoor override changed the zone text.
let c = cache(Some(12), "Elwynn Forest", "Goldshire");
assert_eq!(
elect_event(&c, &sig(12, "Lion's Pride Inn", "", true)),
Some("ZONE_CHANGED_INDOORS")
);
// Inn exit: texts revert, outdoors again → plain ZONE_CHANGED (the frames re-cache
// silently and only the subzone line splashes — the consumer law).
let c = cache(Some(12), "Lion's Pride Inn", "");
assert_eq!(
elect_event(&c, &sig(12, "Elwynn Forest", "Goldshire", false)),
Some("ZONE_CHANGED")
);
// No change → nothing.
let c = cache(Some(12), "Elwynn Forest", "Goldshire");
assert_eq!(
elect_event(&c, &sig(12, "Elwynn Forest", "Goldshire", false)),
@@ -397,17 +330,14 @@ mod tests {
);
}
/// 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.
/// The `0x67e670` override skip (abbey) and fire (inn) branches on the install's data.
#[test]
fn indoor_naming_matches_the_byte_law_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("chain");
let cat = benilla_formats::load_wmo_area_catalog(&mut chain).expect("WMOAreaTable");
// The abbey (WMO 59, Northshire placement name-set 1): the default row's name equals the
// yard subzone ⇒ the zone override SKIPS; group 1934's own name re-fills the subzone.
// Abbey (WMO 59, name-set 1): the default name equals the yard subzone, so no override.
let default = cat.default_row(59, 1).expect("abbey default row");
assert_eq!(default.name, "Northshire Abbey");
let group = cat.group_row(59, 1, 1934).expect("abbey main hall row");
@@ -416,20 +346,15 @@ mod tests {
group.area_table_id, 24,
"the leaf stays Northshire Abbey (24)"
);
// 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.
// Rooms have distinct rows, so a room hop re-fires the dedup.
let library = cat.group_row(59, 1, 1943).expect("library row");
assert_ne!(group.id, library.id, "distinct rows exist per room");
// An unnamed room (group 1935): query B empty ⇒ the subzone keeps what the override
// left — with the override skipped, that's the terrain leaf name.
let unnamed = cat.group_row(59, 1, 1935).expect("unnamed room row");
assert!(unnamed.name.is_empty());
// The Goldshire inn (WMO 53, name-set 2 — its ONLY row is the whole-WMO one): the
// default name differs from the street subzone ("Goldshire") ⇒ the override FIRES (the
// big inn splash), and with no named group row the subzone stays nulled — the un-doubled
// inn entry. Its AreaTableID is 0, so the leaf stays the terrain chunk's (Goldshire).
// Goldshire inn (WMO 53, name-set 2): only a whole-WMO row, named unlike the street, so
// the override fires and the subzone stays nulled.
let inn = cat.default_row(53, 2).expect("the inn's whole-WMO row");
assert_eq!(inn.name, "Lion's Pride Inn");
assert_eq!(inn.area_table_id, 0);
+4 -14
View File
@@ -1,26 +1,16 @@
//! The shared `AreaPOI.dbc` catalog — the world's named points of interest, and the **one** copy
//! of that table in the process.
//!
//! It used to live inside `MinimapAssets`, because the minimap's nearest-3 landmark blips
//! (decision 0203 phase 3) were its only reader. The world map is the second (decision 1586: the
//! `0x4a67a0` landmark pass — city icons, the "Under Attack" indicators, the Eastern Plaguelands
//! towers), and two owners of one DBC is how the two surfaces drift apart on what a POI *is*. So
//! it is hoisted here, beside [`crate::area::AreaTableRes`], on 0287's precedent — the same move,
//! for the same reason, on the sibling table.
//!
//! Loaded once at Startup; absent if the DBC failed to read, which every consumer takes as
//! `Option` and goes quiet about (no landmark blips, no map icons, everything else intact).
//! The one shared `AreaPOI.dbc` catalog, read by the minimap's landmark blips and the world map's
//! landmark pass (`0x4a67a0`). Absent when the DBC fails to load; every reader then shows no POIs.
use bevy::prelude::*;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_formats::AreaPoiCatalog;
/// The shared `AreaPOI.dbc` catalog, in file order (see [`AreaPoiCatalog`]).
/// The `AreaPOI.dbc` rows, in file order.
#[derive(Resource)]
pub(crate) struct AreaPoiRes(pub(crate) AreaPoiCatalog);
/// Startup: load the shared POI catalog off the patch chain.
/// Startup: load the catalog off the patch chain.
fn load_area_poi(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
+29 -86
View File
@@ -1,49 +1,18 @@
//! Area triggers — the client's half of every portal, instance entrance and "explore here" quest.
//! Area triggers: notice the player entering an `AreaTrigger.dbc` volume and send
//! `CMSG_AREATRIGGER`; the server decides what the trigger does.
//!
//! `AreaTrigger.dbc` is a table of invisible volumes. The client's *entire* job is geometry: notice
//! that the player has walked into one, and send `CMSG_AREATRIGGER` naming its id. The **server**
//! decides what the trigger means — a teleport (`areatrigger_teleport`: the Darnassus/Rut'theran
//! portals, every dungeon and raid entrance), a quest's explore objective
//! (`areatrigger_involvedrelation`), the inn's rested state, a battleground's entrance list. The
//! client never knows and never needs to.
//! The reference's check (`0x5e2110`, containment `0x5e22d0`): only the current map's rows are
//! candidates (`0x5e2080`); while the player is still inside the latched trigger nothing is sent;
//! once they leave, the first containing row in file order is latched and sent; a map change
//! clears the latch. So a portal fires once per entry, and a portal's destination is authored
//! outside the return trigger, so a round trip is not a loop.
//!
//! Until this module existed, benilla sent that opcode from nowhere: a portal did nothing, and an
//! instance entrance did nothing, because the server was never told we were standing in one (ledger
//! **B70**, **N02**). The teleport that answers it has worked since decision 0455.
//! The server re-checks the claim with 5 yd of slop and ignores it while taxi-flying
//! (vmangos `Handlers/MiscHandler.cpp:622`).
//!
//! ## The law, from the reference
//!
//! 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
//! `[first, end)` window before any test).
//! - **One latch, and it is an *exit* latch.** The check holds the trigger it is currently inside;
//! while the player is still inside *that* volume it returns immediately and sends nothing. Only
//! once they have left does it scan for a new one — so standing in a portal fires it exactly once,
//! and overlapping volumes never fight.
//! - The **first** containing row in file order wins.
//! - A map change clears the latch.
//!
//! Two things the reference's shape buys us, worth stating because they are load-bearing: a trigger
//! cannot re-fire while you stand in it (a portal would otherwise re-teleport you every frame), and
//! the *destination* of a portal pair is authored to land outside the return trigger (Darnassus's
//! exit lands 13.9 yd from the entrance's 10-yd sphere), so a round trip is not a loop.
//!
//! The server re-checks our claim with 5 yd of slop and ignores it while taxi-flying
//! (`HandleAreaTriggerOpcode`, vmangos `Handlers/MiscHandler.cpp:622`), so a wrong id is refused
//! rather than obeyed.
//!
//! ## ⚠ Probing this with `.go` is not the same as walking in
//!
//! A GM teleport that drops you *inside* a volume sends this message in the same millisecond as the
//! teleport ack, and the server re-checks the claim against the position it has stored — which is
//! sometimes still the pre-teleport one. Measured: five such teleport-ins were obeyed and one
//! (Darnassus's trigger 527) was silently ignored, while drifting into the same trigger under
//! movement fired it and teleported 40 ms later. So an ignored `.go` probe is the harness racing
//! the server, not this check failing — and the latch, faithfully, does not retry (it latches on
//! the *send*; the client never learns whether the server obeyed). Step out and back in, or better,
//! park outside and move in.
//! A GM teleport straight into a volume sends this in the same instant as the teleport ack, and
//! the server may test it against the pre-teleport position and ignore it; the latch does not
//! retry. Probe by moving into the volume instead.
use benilla_assets::coords::bevy_to_wow;
use benilla_formats::AreaTriggerCatalog;
@@ -55,30 +24,18 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use benilla_world::world_map::CurrentMap;
/// The `AreaTrigger.dbc` catalog, bucketed by map. Absent when the client data didn't load — the
/// check then does nothing, like every other data-driven system here.
/// The `AreaTrigger.dbc` catalog, bucketed by map; absent when the data did not load.
#[derive(Resource)]
pub(crate) struct AreaTriggers(pub(crate) AreaTriggerCatalog);
/// The trigger we are currently standing **inside**, with the map it is on — the reference's single
/// "current trigger" pointer (`DAT_00c4d73c`).
///
/// Keyed by map so it can never leak across a worldport (the reference clears it on the map change;
/// carrying the map id makes the same guarantee without a hook). It deliberately survives a
/// reconnect on the same map, which is also what the reference does — its clear runs off the map
/// change, not the socket — and it is the safer half of that coin: reconnecting inside a portal
/// does not teleport you.
/// The latched trigger and its map: the reference's current-trigger pointer (`0xc4d73c`), cleared
/// on a map change and kept across a same-map reconnect, as the reference does.
#[derive(Resource, Default)]
pub(crate) struct InsideTrigger(Option<(u32, u32)>);
impl InsideTrigger {
/// One check, as the reference orders it (`0x5e2110`): if we are still inside the latched
/// volume, report nothing; otherwise unlatch, and latch + report the **first** trigger on this
/// map containing `p`, if any. `Some(id)` means "send `CMSG_AREATRIGGER` for this one".
///
/// Pure, and separate from the system, because the property that matters is a state machine
/// rather than a query: it must fire **once** per entry. Firing per frame would be a packet
/// flood at best and, on a teleport trigger, an infinite loop — arrive, fire, arrive.
/// One check in the reference's order (`0x5e2110`): nothing while still inside the latched
/// volume, else latch and return the first trigger on this map containing `p`.
fn step(&mut self, triggers: &AreaTriggerCatalog, map_id: u32, p: [f32; 3]) -> Option<u32> {
if let Some((latched_map, id)) = self.0 {
let still_in = latched_map == map_id
@@ -104,9 +61,8 @@ impl Plugin for AreaTriggerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InsideTrigger>()
.add_systems(Startup, load_area_triggers.after(AssetSet::Open))
// In the Stream band: after Input has moved the avatar (and applied any teleport
// snap), so the position tested is the one this frame ends at — the same one the
// movement stream reports, which is what the server re-checks us against.
// After Input has moved the avatar, so the position tested is the one the movement
// stream reports, which the server re-checks the claim against.
.add_systems(
Update,
check_area_triggers
@@ -128,7 +84,7 @@ fn load_area_triggers(mut commands: Commands, assets: Option<Res<WorldAssets>>)
}
}
/// The per-frame check — the reference's `0x5e2110`, in the same order it runs.
/// The per-frame check (`0x5e2110`).
fn check_area_triggers(
triggers: Option<Res<AreaTriggers>>,
map: Option<Res<CurrentMap>>,
@@ -139,8 +95,7 @@ fn check_area_triggers(
let (Some(triggers), Some(map)) = (triggers, map) else {
return;
};
// Before the server has placed us, `pos` is wherever the free-flying camera left the avatar —
// not a position the server would recognise, and not one worth reporting.
// Before the server has placed us, `pos` is not a server position.
if !player.active {
return;
}
@@ -148,11 +103,8 @@ fn check_area_triggers(
let Some(trigger_id) = inside.step(&triggers.0, map.0, here) else {
return;
};
// Fire-and-forget, like every other send: a down write thread drops it.
let _ = net.0.send(ClientCommand::AreaTrigger { trigger_id });
// At `info`, deliberately: entering a trigger is rare (you must cross a volume boundary), and
// when a portal or instance entrance "does nothing", the first question is whether the client
// saw the volume at all. One line answers it.
// At `info`: entries are rare, and this line tells whether the client saw the volume at all.
info!(
"area_trigger: entered {trigger_id} on map {} at [{:.2}, {:.2}, {:.2}]",
map.0, here[0], here[1], here[2]
@@ -169,18 +121,14 @@ mod tests {
Some(benilla_formats::load_area_trigger_catalog(&mut chain).expect("AreaTrigger.dbc"))
}
/// The whole state machine over the **real** table, walking the exact route the live probe
/// walked: the Darnassus portal pair (B70) and the Southshore inn's box (the shape the sphere
/// tests can't reach). Skips without client data.
///
/// The second assertion is the load-bearing one — a portal that reported every frame would
/// teleport you the instant you arrived, forever.
/// The state machine over the real table: the Darnassus portal pair and the Southshore inn's
/// box trigger. Skips without client data.
#[test]
fn a_trigger_fires_once_per_entry_and_re_arms_on_leaving() {
let Some(cat) = real_catalog() else { return };
let mut inside = InsideTrigger::default();
// Rut'theran Village's portal (542, a 10-yd sphere): step in, and it reports once.
// Rut'theran Village's portal (542, a 10 yd sphere).
let ruttheran = [8799.41, 969.787, 30.2409];
assert_eq!(inside.step(&cat, 1, ruttheran), Some(542));
assert_eq!(
@@ -189,29 +137,24 @@ mod tests {
"standing in a portal must not re-report it — that is the teleport loop"
);
// The server answers by putting us at the Darnassus end. That landing spot is deliberately
// OUTSIDE the return trigger (527, also 10 yd) — 17.2 yd from its centre, and 13.9 yd the
// other direction — which is why a round trip is not a loop. Nothing fires, and the latch
// re-arms.
// The Darnassus landing spot lies outside the return trigger (527, 10 yd), 17.2 yd from
// its centre: nothing fires and the latch re-arms.
let darnassus_arrival = [9946.25, 2612.97, 1316.49];
assert_eq!(inside.step(&cat, 1, darnassus_arrival), None);
// Walk into the return portal proper: it reports, having re-armed.
assert_eq!(inside.step(&cat, 1, [9947.48, 2630.04, 1318.6]), Some(527));
// A map change cannot leak the latch: the Southshore inn's BOX trigger (708) is on map 0
// and fires immediately, even though a trigger is still latched from map 1.
// The Southshore inn's box trigger (708) on map 0 fires while one is latched on map 1.
assert_eq!(
inside.step(&cat, 0, [-854.547, -576.314, 18.4659]),
Some(708)
);
assert_eq!(inside.step(&cat, 0, [-854.593, -576.207, 18.5563]), None);
// Deadmines' entrance (78) — the instance-portal case (N02), a 7-yd sphere on map 0.
// The Deadmines entrance (78), an instance portal: a 7 yd sphere on map 0.
assert_eq!(inside.step(&cat, 0, [-11208.5, 1685.34, 25.7612]), Some(78));
}
/// Empty air reports nothing, and an unknown map is not an error.
#[test]
fn open_ground_and_unknown_maps_are_silent() {
let Some(cat) = real_catalog() else { return };
+9 -26
View File
@@ -1,20 +1,7 @@
//! `WOW_ASSET_CHURN=1` — what the app rewrites in `Assets<T>` every frame, and how big it is.
//!
//! The question this answers: Bevy's `prepare_assets<GpuImage>` re-uploads any image that was
//! **modified** since last frame, and `extract_render_asset` re-extracts it. Both showed up as a
//! flat multi-millisecond per-frame cost on the login screen — a static background image — where
//! nothing should be modified at all. A trace can name the *system* that pays; only the asset
//! events name the *asset* that made it pay.
//!
//! Prints one line per second: for each watched asset type, the per-frame Added/Modified counts,
//! and for images the megabytes those modifications ask the render world to re-upload, plus the
//! top offenders by how many frames they were touched in.
//!
//! The megabytes come from the image's `texture_descriptor`, not from `data.len()`. A
//! `RenderAssetUsages::RENDER_WORLD` image has its bytes MOVED into the render world on extract, so
//! main-side `data` is `None` from then on — measuring it read 0 for the sprite sheets, the effect
//! textures and all three terrain arrays, i.e. for most of the texture set. The meter was blind to
//! precisely the assets it exists to catch.
//! `WOW_ASSET_CHURN=1`: once a second, logs per asset type the Added/Modified events per frame,
//! the image megabytes those modifications re-upload, and the images touched in the most frames.
//! Sizes come from the `texture_descriptor`: a `RENDER_WORLD` image's main-side `data` is `None`
//! after extract.
use std::collections::HashMap;
@@ -25,8 +12,7 @@ use bevy::time::Real;
pub(crate) struct AssetChurnPlugin;
/// Off unless `WOW_ASSET_CHURN=1`, read once (the plugin is skipped entirely, so an ordinary run
/// carries no systems at all — not even a disabled one).
/// Off unless `WOW_ASSET_CHURN=1`, read once; when off the plugin adds no systems.
pub(crate) fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_ASSET_CHURN").as_deref() == Ok("1"))
@@ -47,12 +33,9 @@ struct Churn {
frames: u32,
/// asset type -> (added, modified) event totals this window.
counts: HashMap<&'static str, (u64, u64)>,
/// Image id -> how many DISTINCT frames it was modified in, the last frame index that counted
/// (so several Modified events in one frame count once), and its byte size when last seen.
/// Counting events instead printed "touched in 77/9 frames" — a ratio above 1.0 is the
/// instrument telling you it is measuring something other than what its label says.
/// Per image: distinct frames modified, not events, so the ratio never exceeds 1.
images: HashMap<AssetId<Image>, ImageChurn>,
/// Bytes of image data modification asks the render world to re-upload, this window.
/// Image bytes modification asks the render world to re-upload, this window.
image_bytes: u64,
last_report: f32,
}
@@ -62,9 +45,9 @@ struct Churn {
struct ImageChurn {
/// Distinct frames this image was modified in.
frames: u64,
/// The last frame index already counted, so N events in one frame count once.
/// The last frame counted, so several events in one frame count once.
last_frame: Option<u32>,
/// Its data size when last seen (what a modification asks to be re-uploaded).
/// Its GPU size when last seen.
bytes: usize,
}
+114 -384
View File
@@ -1,101 +1,21 @@
//! **The aura-state CharProc layer** — what an aura does to the *body itself*: its translucency, its
//! tint and its animation clock, for exactly as long as the aura lives. This is the half of a stage-2
//! "state kit" that
//! isn't an attach-point emitter (those are `creature_anim::spell_visual`'s
//! [`arm_aura_state_fx`](crate::creature_anim::arm_aura_state_fx), decision 0393): where that
//! watcher hangs *models* on the unit, this one changes how the unit's own model **renders**.
//! The aura-state CharProc layer: what an aura does to its unit's own body (translucency, tint,
//! animation clock) while it lives; the kit's attach-point models are
//! [`arm_aura_state_fx`](crate::creature_anim::arm_aura_state_fx)'s.
//!
//! B114 ("Stealth shows nothing on the character") was this whole layer missing. Stealth's state
//! 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.
//! In the reference an aura slot change (`0x604d00` → `0x6123f0`) reaches `0x5ff350`, which plays
//! the spell's state kit (`SpellVisual` field 4) at stage 2, and the kit's tail (`0x60f35c`) runs
//! the CharProc dispatcher `0x60d7c0` over its four proc slots (`SpellVisualKit` fields 15-34).
//! Three procs act on the body: 14 installs an alpha node (`0x60d972`), 1 a tint node
//! (`0x60d840`), 11 an animation rate (`0x60db7e`). A node is keyed by spell id and linked at the
//! head of its list, only the head counts, and the aura's removal drops its spell's nodes
//! (`0x5ff290`).
//!
//! ## The mechanism
//! `UNIT_FIELD_BYTES_1` byte 3's stealth and ghost bits drive no body render in the reference: its
//! readers (`0x5ff80d`, `0x607101`, `0x60f62e`) only suppress nameplates and markers. The body
//! keys on the aura, never on the flag.
//!
//! `UNIT_FIELD_AURA` slot change → the aura watcher `0x604d00` → `0x6123f0` → **`0x5ff350`
//! PlayAuraStateVisual**: the slot's spell → `Spell.dbc` → `SpellVisual` → **field 4 = the state
//! kit** → `PlaySpellVisualKit(kit, stage 2)`, whose tail (`0x60f358: push esi(kit);
//! 0x60f35c: call 0x60d7c0`) runs the **CharProc dispatcher** over the kit's four proc slots
//! (`SpellVisualKit` fields 15–34 — `benilla_formats::CharProc`). Two of the dispatcher's nine cases
//! act on the body:
//!
//! - **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 — `baseAlpha` from the
//! vtbl+0x6c getter `0x60d2d0` (`CreatureDisplayInfo+0x14 × 1/255`, the SAME slot in the unit
//! 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`
//! (`0x614baa → 0x710cb0`), which multiplies into the per-batch alpha (`0x707680`).
//! The recompute has a second, aura-free caller: the DISPLAYID watcher's refresh (`0x60abe0 →
//! 0x60ad9f`), which is how a display whose row says `CreatureModelAlpha < 255` — Ghost Wolf's
//! 4613 = 102 — renders translucent by itself; [`refresh_base_alpha`] is that leg.
//! - **proc 1 = tint** (`0x60d840`): `round(param)` is a packed `0x00RRGGBB` OR'd with `0xff000000`
//! into a tint node (list `unit+0xce0`); per frame the head node goes `×1/255` into
//! `model+0x184/188/18c`.
//!
//! Removal is the mirror image: the aura leaves the slot → `0x5ff290` drops that spell's nodes and
//! recomputes, so the alpha **ramps back** to the new target over the same 1000 ms.
//!
//! 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 (`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)
//!
//! 185 of 1772 kits carry a CharProc, and the **state** stage is far and away their home: 103 state
//! kits, ~1200 (spell, proc) pairs. Proc 14 covers **11 kits / 135 spells** — kit 312 `0.3` is the
//! whole sneak family (Stealth 1784-1787, Prowl, Vanish, Shadowmeld, Hide, Sneak, Disguise), kit
//! 3450 `0.5` is Invisibility/Fade, kit 989 `0.5` the ghost aura, 3129 `0.65` Shadowform, 1286 `0.3`
//! Banish, 3989 `1.0` Possess, 5129 `0.0` a quest fade-out. Proc 1 covers 73 state kits (~800
//! spells): Frostbolt's chill blue, Immolate's ember orange, the poison green.
//!
//! ## What is built here, and what is not
//!
//! **Proc 14 ships whole** — nodes, the `baseAlpha × head node` target, the 1000 ms cubic ramp both ways,
//! and the render composition below.
//!
//! **Proc 1 ships too** (decision 0812): its nodes live here ([`AuraNodes::tint`], head-node-wins as
//! the reference's `unit+0xce0` list is) and [`apply_aura_tint`] publishes the head to
//! [`benilla_world::instance_tint`], the per-instance modulate channel keyed on the part's rig slot. Written
//! on change, never eased — the reference's own asymmetry (`unit+0xd04` change-detection straight to
//! `0x710cf0`, with none of the alpha's 1000 ms ramp).
//!
//! 0806 recorded this as blocked on a render-architecture call between per-unit material clones and a
//! tag-indexed palette. That framing was wrong on its own terms: the `MeshTag` rig field already IS a
//! per-instance slot into the shared light buffer, so the tint needed **no** new tag bits (which was
//! the whole stated blocker — the payload has 5 spare bits outdoors and zero indoors) and no material
//! churn. What it did need was reading our own renderer properly.
//!
//! The tint reaches a unit **whole** — the reference's `0x714000` recursion, where an attached model
//! composes onto its parent CM2's computed colours — by two routes, because benilla has two kinds of
//! attached model:
//!
//! - the ordinary one **borrows its wearer's slot**: an item's parts, cards and boneless geosets all
//! spawn carrying the unit's instance bits (decision 0820), so they read the unit's table entry;
//! - a **rigged** one carries its OWN slot, because the vertex stage indexes the skin palette with
//! that same field and cannot borrow it (a spell-effect instance; since 0841, the seven shoulder
//! models that weld geometry to a billboard bone). [`chained_tint`] walks such an instance's
//! [`benilla_world::model_fade::ParentModel`] link up to the unit — the recursion itself, and the same walk
//! `ModelAlphas` already does for alpha.
//!
//! **Proc 11 ships too** (decisions 0889 + 0891): the **freeze**. Its param is a playback *rate*
//! written onto the unit's own animation clocks (`0x60db7e` → `SetBoneAnimSpeed 0x712910` on the
//! mount's bone 0, the body's key-bone 4 — the upper-body split — and the body's bone 0), old rates
//! saved in the node and written back when it expires (`0x6203e0`). Rate 0 is Ice Block: the pose
//! holds exactly where it was, *including* a cast one-shot that had just been armed, which is why the
//! caster never gets to raise their hand. [`apply_aura_anim_rate`] is that leg — and it expresses
//! rate 0 as a **pause**, not as a speed, because the reference's rate belongs to a bone while a speed
//! here belongs to a clip that other code copies from ([`AnimRateFreeze`] carries the story).
//!
//! 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 mechanism is known.
//! Proc types 2, 7 and 13 also appear on state kits; their mechanism is untraced, so [`node_for`]
//! ignores them.
use benilla_protocol::EntityKind;
use bevy::mesh::MeshTag;
@@ -105,75 +25,51 @@ use benilla_assets::materials::WowModelMaterial;
use benilla_world::interior::InteriorLit;
use benilla_world::model_fade::{fade_alpha, FadeMaterials, PendingAppearFade, RenderFade};
/// The reference's aura-alpha ramp: `StartAlphaFade(target, **1000 ms**)` (`0x614f80` from
/// `0x60d180`'s recompute), eased `clamp01(t)³` by `0x614a90` — [`fade_alpha`] is that curve.
///
/// It is a **code constant, not a kit column**: proc 14's `params[1..]` are `[2.0, 1000.0, 0.0]` on
/// the sneak kit but `[0.0, 0.0, 0.0]` on the ghost's, and the ghost's ramp is byte-cited at the same
/// 1000 ms — so the sneak kit's literal `1000.0` cannot be the source (a data-sourced duration would
/// snap the ghost instantly). Across the whole shipped table proc 14's `params[2]` is only ever
/// `0.0`, `2.0` or `1000.0`, which is no duration scale in any unit. Read from `params[0]` only.
/// The aura-alpha ramp, `StartAlphaFade(target, 1000 ms)` (`0x614f80`) eased `clamp01(t)³` by
/// [`fade_alpha`] (`0x614a90`). A code constant, not a kit column: the ghost kit's proc 14 carries
/// no `1000.0` yet ramps over the same second.
pub(crate) const AURA_ALPHA_FADE_SECS: f32 = 1.0;
/// Settled-ramp epsilon — under half a step of the tag's 6-bit alpha field (1/64), so a ramp that
/// has visually arrived is treated as arrived.
/// Under half a step of the tag's 6-bit alpha field (1/64): a ramp this close has arrived.
const ALPHA_EPS: f32 = 1.0 / 128.0;
/// One CharProc node the dispatcher installed on a unit, keyed by the aura's spell id exactly as the
/// reference keys `node+0x18`. Held in [`AuraNodes`]; a `Reap` for that spell id removes it.
/// One CharProc node a kit installs on a unit, keyed by spell id as the reference keys `node+0x18`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum AuraNode {
/// Proc 14: this aura's alpha factor (`node+0x78`) — the target's node term while it is the head.
/// Proc 14: the alpha factor (`node+0x78`).
Alpha(f32),
/// Proc 1: this aura's body tint, unpacked from the param's `0x00RRGGBB`. Modelled, not yet
/// rendered (module docs).
/// Proc 1: the body tint, unpacked from the param's `0x00RRGGBB`.
Tint([u8; 3]),
/// Proc 11: this aura's animation playback **rate** for the unit's own model — `0.0` for every
/// member of the freeze family (Ice Block, Freeze, Petrify, Entangle, Stilled).
/// Proc 11: the playback rate of the unit's own clocks, `0.0` for the whole freeze family.
AnimRate(f32),
}
/// The CharProc node lists of one unit — the ECS twin of the reference's per-unit `unit+0xb50`
/// (alpha) and `unit+0xce0` (tint) lists, plus the ramp `0x614f80` drives from their heads.
///
/// Lives on the **net entity root**, like [`benilla_world::entity_shade::GroundShade`]: the reference has one
/// of these per CGUnit and every attached model (held item, helm, shoulder) renders off the owner's,
/// which is also why [`apply_aura_alpha`] walks the root's whole descendant tree rather than its
/// direct children.
/// One unit's CharProc node lists (the reference's `unit+0xb50` alpha and `unit+0xce0` tint lists)
/// and its alpha ramp. On the net entity root: the reference keeps one per CGUnit, and every
/// attached model renders off its owner's.
#[derive(Component, Debug, Default)]
pub(crate) struct AuraNodes {
/// `(spell id, factor)` — proc-14 nodes, newest at the front (the reference links a fresh node
/// at the list head). The HEAD times [`Self::base`] is the target — never a product.
/// Proc-14 `(spell id, factor)` nodes, newest first, as the reference links at the head.
alpha: Vec<(u32, f32)>,
/// `(spell id, rgb)` — proc-1 nodes, newest at the front like the alpha list; the head is what
/// the per-frame apply reads.
/// Proc-1 `(spell id, rgb)` nodes, newest first.
tint: Vec<(u32, [u8; 3])>,
/// `(spell id, rate)` — proc-11 nodes, head-first like the other two. The head is the rate the
/// unit's animation clocks run at ([`apply_aura_anim_rate`]); empty = the clocks are the
/// driver's own again.
/// Proc-11 `(spell id, rate)` nodes, newest first; the head drives the unit's clocks.
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.
/// 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.
/// `baseAlpha`, the display row's `CreatureModelAlpha / 255` for every unit, players included
/// (`0x60d2d0`); kept current by [`refresh_base_alpha`].
base: f32,
/// Where the ramp is now — what the parts actually render at.
/// The ramp's live value, which the parts render at.
current: f32,
/// The ramp: `from → to` over [`AURA_ALPHA_FADE_SECS`] from `started` (`Time::elapsed_secs`).
/// The ramp, `from` to `to` over [`AURA_ALPHA_FADE_SECS`] from `started` (elapsed seconds).
from: f32,
to: f32,
started: f32,
/// Whether [`apply_aura_alpha`] still owns this unit's parts. Set while the unit is translucent
/// and for the one frame the ramp latches back at opaque — that frame performs the **release**
/// (writes the settled alpha and hands the material back to the part's light law), the same
/// hand-back protocol the self-avatar feather uses (decision 0213). Without it, a unit whose
/// stealth drops stays latched on the blend twin at its last low alpha until something else
/// happens to rewrite the channel.
/// Whether [`apply_aura_alpha`] owns this unit's parts: while translucent, plus the one frame
/// at opaque that writes the settled alpha and hands the material back to its light law.
authoring: bool,
}
impl AuraNodes {
/// A fresh node set for a unit whose `baseAlpha` is `base`, ramp settled at `base`.
fn new(base: f32) -> Self {
Self {
base,
@@ -184,18 +80,15 @@ 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. Nodes are linked at the head as they install, so the newest one is the term that
/// counts.
/// `0x60d180`: `baseAlpha` times the head alpha node's factor alone, never a product over the
/// list; an empty list leaves `baseAlpha` (`0x60d195`).
fn target(&self) -> f32 {
let head = self.alpha.first().map_or(1.0, |(_, f)| *f);
(self.base * head).clamp(0.0, 1.0)
}
/// Point the ramp at the current target, from wherever it is now. A no-op when the target
/// hasn't moved, so an aura refresh (or a node whose factor equals the live one) doesn't restart
/// the ease.
/// Aim the ramp at the target from where it is now; an unmoved target leaves it alone, so an
/// aura refresh does not restart the ease.
fn retarget(&mut self, now: f32) {
let target = self.target();
if (target - self.to).abs() <= f32::EPSILON {
@@ -206,34 +99,27 @@ impl AuraNodes {
self.started = now;
}
/// Advance the ramp to `now` and return the live alpha.
fn tick(&mut self, now: f32) -> f32 {
let t = (now - self.started) / AURA_ALPHA_FADE_SECS;
self.current = fade_alpha(self.from, self.to, t);
self.current
}
/// Whether the unit renders translucent right now (the ramp has arrived at, or is heading
/// anywhere below, opaque).
fn translucent(&self) -> bool {
self.current < 1.0 - ALPHA_EPS
}
/// The head tint node's RGB — the reference's `unit+0xce0` head, which its per-frame apply reads
/// (`0x60cbd9`). `None` when the unit carries no tint aura. Published to the render channel by
/// [`apply_aura_tint`].
/// The head tint node's RGB, which the reference's per-frame apply reads (`0x60cbd9`).
pub(crate) fn head_tint(&self) -> Option<[u8; 3]> {
self.tint.first().map(|(_, rgb)| *rgb)
}
/// The head proc-11 node's rate — what this unit's animation clocks are being held at, or `None`
/// when no freeze aura is on it. Head-node-wins like the other two lists.
/// The head proc-11 node's rate, the one the unit's clocks run at.
pub(crate) fn head_anim_rate(&self) -> Option<f32> {
self.rate.first().map(|(_, r)| *r)
}
/// A node list holding one proc-11 rate node — the state a freeze aura leaves on a unit, for
/// the driver's wound-refusal tenant (decision 2063) without running the aura-slot watcher.
/// A node set holding one proc-11 node, as a freeze aura leaves it, without the slot watcher.
#[cfg(test)]
pub(crate) fn with_rate_node_for_tests(spell_id: u32, rate: f32) -> Self {
Self {
@@ -243,20 +129,9 @@ impl AuraNodes {
}
}
/// Publish every rig's body tint to the per-instance channel ([`benilla_world::instance_tint`], decision
/// 0812): the head node's RGB packed the reference's way, keyed on the unit's rig slot — the slot
/// each of its skinned parts already carries in its `MeshTag`, and which the fragment stage reads to
/// index the table.
///
/// **On change, not eased** — the reference's own asymmetry between the two node lists: the alpha
/// gets a 1000 ms cubic ramp (`0x614f80`), the tint goes straight in on change-detection
/// (`unit+0xd04` → `0x710cf0`). `InstanceTints::set` is idempotent, so the steady state costs one
/// compare per rig and never touches the upload.
///
/// Iterates every **`RigSkin`**, not every `AuraNodes`, deliberately: reasserting the whole table
/// each frame means no edge can leak a stale colour into a recycled slot — not an `AuraNodes` that
/// went away, not a unit whose last tint aura dropped. (The despawn/rebuild edge is covered
/// structurally too, in `RigSkin`'s free hook.)
/// Publish every rig's head tint to [`benilla_world::instance_tint`], keyed on its rig slot. The
/// reference writes the tint as it changes, with none of the alpha's ramp (`unit+0xd04` →
/// `0x710cf0`). Every `RigSkin` is rewritten each frame so a recycled slot never keeps a colour.
pub(crate) fn apply_aura_tint(
rigs: Query<(Entity, &benilla_world::rig_palette::RigSkin)>,
chain: Query<(
@@ -282,15 +157,9 @@ pub(crate) fn apply_aura_tint(
}
}
/// The tint a rig instance renders with: its own head node, else the nearest one **up its
/// [`benilla_world::model_fade::ParentModel`] chain** — the reference's `0x714000` recursion, which composes
/// an attached model's colours onto its parent's computed ones (the same walk [`ModelAlphas`] does
/// for alpha, and the same law `mesh_tag` cites for why an item's parts carry their wearer's slot).
///
/// It exists because a rigged **item** carries its own instance slot rather than its wearer's
/// (decision 0841 — the vertex stage indexes the palette with that field, so it cannot be borrowed):
/// without the walk, a dwarf's Stoneform tint would stop at exactly the pauldrons the rig was added
/// for. A despawned link ends the walk.
/// The tint a rig renders with: its own head node, else the nearest up its
/// [`benilla_world::model_fade::ParentModel`] chain, as the reference composes an attached model's
/// colours onto its parent's (`0x714000`); only this walk reaches a rigged item's own slot.
fn chained_tint(
chain: &Query<(
Option<&AuraNodes>,
@@ -311,16 +180,9 @@ fn chained_tint(
None
}
/// `WOW_TINT_PROBE=RRGGBB` — paint **every live rig slot** that colour, auras ignored.
///
/// The one machine check for the GPU half of the channel: the region upload, the fragment stage's
/// slot lookup, the unpack, and the placement of the multiply. No headless test can reach any of
/// that, and the capture harness cannot stage a real aura to do it with — it sees no networked
/// entities at all (0799), so a tinted *unit* is unstageable there. Pointed at a scene with animated
/// (rigged) doodads it turns each of them the probe colour, which the visual harness then diffs.
///
/// It logs the slot count once, so an unchanged capture reads as "nothing in this scene is rigged"
/// rather than as "the channel is broken" — the two are indistinguishable from the image alone.
/// `WOW_TINT_PROBE=RRGGBB` paints every live rig slot that colour, auras ignored: the machine check
/// for the GPU half of the tint channel. It logs the slot count once, since an unchanged capture
/// cannot tell "nothing is rigged" from "the channel is broken".
fn tint_probe() -> Option<[u8; 3]> {
static PROBE: std::sync::OnceLock<Option<[u8; 3]>> = std::sync::OnceLock::new();
*PROBE.get_or_init(|| {
@@ -335,54 +197,21 @@ fn tint_probe() -> Option<[u8; 3]> {
})
}
/// Marker: this rig's clocks are being held by a proc-11 aura, so the release knows to let them go.
///
/// It carries **nothing**, and that is the fix for the first shipped version's bug. That one stored
/// the speed of every clip it took over and wrote the values back on release — the reference's own
/// `+0x60`/`+0x64`/`+0x68` save/restore, transliterated one level too low. It cannot work here,
/// because in the reference a rate belongs to a *bone* (one value, saved once, and clips armed later
/// never carry one) while ours would have to belong to a *clip*, and clips get armed under the freeze
/// carrying values copied from other, already-frozen clips. `transplant_up` is exactly that: moving a
/// one-shot to the torso overlay copies the source clip's speed, which under the freeze was `0`, so
/// the overlay was saved at `0`, restored to `0`, and never advanced again — a cast pose welded to the
/// upper body for the rest of the session, surviving every later cast (it never *finished*, so the
/// overlay never released). Pausing owns no value and so cannot poison one.
/// Marker: a proc-11 aura holds this rig's clocks. It saves no speeds, unlike the reference's
/// per-bone save and restore (`0x6203e0`): here a rate belongs to a clip, and `transplant_up` arms
/// clips under the freeze with speeds copied from frozen ones, so a saved speed restores as 0.
#[derive(Component, Default, Debug)]
pub(crate) struct AnimRateFreeze;
/// Hold the clocks of every unit whose head proc-11 node says so — its own rig and its mount's
/// (`[unit+0xd8]` and `[unit+0xdc]`, the two models `0x6201d0` writes) — and let them go when the aura
/// does.
/// Pause the clocks of every unit whose head proc-11 rate is 0, its own rig and its mount's (the
/// two models `0x6201d0` writes), and resume them when the aura goes; effect models and global
/// sequences keep running. The pause is reasserted every frame, since the reference's rate lives
/// on the bone (`+0xb0`, read by the clock at `0x71458e`) and arming leaves it alone
/// (`0x7121a0`), and after the animation driver, so a cast armed this frame freezes too.
///
/// **A rate of 0 is expressed as a PAUSE, not as `set_speed(0.0)`.** They are the same picture on
/// screen (Bevy evaluates a paused animation at its frozen seek, exactly as `0x712910`'s `bias` rebase
/// holds the reference's current frame) but not the same object: a pause is a bit the clock reads,
/// where a speed is a *value* other code copies. `transplant_up` copies a clip's speed when it moves a
/// one-shot to the torso overlay, and that is how the first version welded a cast pose to the upper
/// body permanently — see [`AnimRateFreeze`] and decision 0891. Nothing here reads or writes a speed, so nothing can
/// 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 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
/// across every re-arm in between, and re-pausing each frame is how a per-clip flag says the same
/// thing — including for clips armed *under* the freeze, which the reference freezes by construction.
///
/// It runs **after the driver**, so this frame's arms are already in — which is the whole Ice Block
/// observable: the cast one-shot is armed and then frozen at the frame it was on, so the caster never
/// gets to raise their hand.
///
/// **A non-zero rate does nothing** (and only kit 1744's `8947848.0` is one — a packed grey that
/// landed in the rate column, on Stoned / Petrification / Thadius Spawn). Our clock is f32 seconds
/// where the reference's is integer milliseconds with a modulo, so a rate that large is noise there
/// and different noise here; there is no honest transliteration, and inventing one would be worse than
/// saying so. Recorded, not special-cased.
///
/// What it deliberately does **not** touch: attached effect models (the ice block's own
/// `icebarrier_state.mdx` keeps shimmering — the reference writes the unit's two models only) and
/// global sequences (a different clock there, and a different writer here).
/// Deviation: a non-zero rate is ignored, because the only one shipped (`8947848.0` on kit 1744, a
/// packed grey in the rate column) is noise on the reference's integer-ms clock and has no faithful
/// form on our f32-second one.
pub(crate) fn apply_aura_anim_rate(
units: Query<(
Entity,
@@ -409,10 +238,7 @@ pub(crate) fn apply_aura_anim_rate(
}
}
(false, true) => {
// Resume everything: the set we paused is "whatever was playing", and it grew
// every frame the freeze held. Nothing else on a unit rig pauses an animation
// (the portrait booth does, on its own rigs), so there is no third party's pause
// to trample.
// Resume every clip: nothing else pauses an animation on a unit rig.
for (_, anim) in player.playing_animations_mut() {
anim.resume();
}
@@ -425,12 +251,8 @@ pub(crate) fn apply_aura_anim_rate(
}
}
/// The freeze's own instrument (`WOW_MOVE_TRACE`, tag `aur`): one line per rig at each edge, naming
/// the clips it took over and the speed each is *left* running at — the difference between "the clocks
/// stopped" and "nothing was playing anyway", which no count of *absent* anim events can tell apart.
///
/// It prints the speeds precisely because the first version's bug was invisible without them: a `@0`
/// on the thaw line is a clip that will never advance again.
/// Trace (`WOW_MOVE_TRACE`, tag `aur`) one line per rig at each freeze edge, with each clip's
/// speed: a `@0` on a thaw line is a clip that will never advance again.
fn trace_clock_edge(what: &str, rig: Entity, player: &AnimationPlayer) {
if !benilla_assets::trace::enabled_for("aur") {
return;
@@ -445,9 +267,7 @@ fn trace_clock_edge(what: &str, rig: Entity, player: &AnimationPlayer) {
);
}
/// One aura's CharProc edge, written by the aura-slot watcher
/// ([`crate::creature_anim::arm_aura_state_fx`], which owns the slot diff and the state-kit resolve
/// for both halves of a kit) and drained by [`drain_aura_procs`].
/// One aura's CharProc edge, from [`crate::creature_anim::arm_aura_state_fx`]'s slot watch.
#[derive(Message, Clone, Debug)]
pub(crate) enum AuraProc {
/// The aura landed in a slot: install this kit's body procs, keyed by its spell id.
@@ -461,16 +281,14 @@ pub(crate) enum AuraProc {
Reap { entity: Entity, spell_id: u32 },
}
/// Turn one `SpellVisualKit` CharProc into the node it installs, or `None` for a proc type we have
/// no verified mechanism for (module docs). **The single dispatch point** — the ECS twin of the
/// client's jump table `0x60dbfc`, and the one place a newly-REd proc type gets an arm.
/// The node one `SpellVisualKit` CharProc installs (the dispatcher's jump table `0x60dbfc`), or
/// `None` for a proc type whose mechanism is untraced.
pub(crate) fn node_for(proc: benilla_formats::CharProc) -> Option<AuraNode> {
use benilla_formats::char_proc_type as ty;
match proc.ty {
ty::ALPHA => Some(AuraNode::Alpha(proc.params[0])),
// `round(param)` → `0x00RRGGBB` (`0x60d8cc` then ORs the opaque byte on). The param is a
// float column holding an integral colour: the ghost's 9222653.0 is 0x8CB9FD, pale
// blue-white; the poison kit's 65280.0 is 0x00FF00.
// A float holding an integral `0x00RRGGBB`, rounded (`0x60d8cc`): the ghost's 9222653.0
// is 0x8CB9FD.
ty::TINT => {
let packed = proc.params[0].round().clamp(0.0, 0xff_ffff as f32) as u32;
Some(AuraNode::Tint([
@@ -479,22 +297,14 @@ pub(crate) fn node_for(proc: benilla_formats::CharProc) -> Option<AuraNode> {
packed as u8,
]))
}
// `params[0]` is a playback rate, straight through — `0x60db7e` hands it to
// `SetBoneAnimSpeed` unexamined, so the one outlier (kit 1744's `8947848.0`, a packed grey
// that landed in the rate column) is passed through rather than special-cased.
// A playback rate, passed through: `0x60db7e` hands it to `SetBoneAnimSpeed` (`0x712910`)
// unexamined.
ty::ANIM_RATE => Some(AuraNode::AnimRate(proc.params[0])),
_ => None,
}
}
/// Apply this frame's [`AuraProc`] edges to the units' node lists, then re-aim every live ramp.
///
/// `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`, 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).
/// Apply this frame's [`AuraProc`] edges to the units' node lists.
pub(crate) fn drain_aura_procs(
mut edges: MessageReader<AuraProc>,
time: Res<Time>,
@@ -504,7 +314,6 @@ pub(crate) fn drain_aura_procs(
creatures: Option<Res<crate::entities::Creatures>>,
) {
let now = time.elapsed_secs();
// Node sets for units that don't carry the component yet — see the `Begin` arm.
let mut fresh: bevy::ecs::entity::EntityHashMap<AuraNodes> = Default::default();
for edge in edges.read() {
match edge {
@@ -520,14 +329,8 @@ pub(crate) fn drain_aura_procs(
install(&mut n, *spell_id, procs, now);
trace_edge("arm", *entity, *spell_id, &n);
} else {
// First edge for this unit: stage the node set locally rather than inserting
// straight away. `Commands` are deferred to the end of the system, so a unit
// that streams in already carrying two aura procs (or gets two in one packet
// burst) emits two Begins this frame and the second would `get_mut` the
// still-absent component, build a *fresh* set, and insert over the first —
// silently keeping only the last aura's factor. Staging makes the outcome of a
// same-frame burst come out right, which is the common case for a unit observed
// mid-buff, not a corner.
// Staged, not inserted: `Commands` apply at the end of the system, so a second
// Begin this frame would build a fresh set and overwrite the first.
let n = fresh.entry(*entity).or_insert_with(|| {
AuraNodes::new(base_alpha(*entity, &units, creatures.as_deref()))
});
@@ -540,8 +343,7 @@ pub(crate) fn drain_aura_procs(
reap(&mut n, *spell_id, now);
trace_edge("reap", *entity, *spell_id, &n);
} else if let Some(n) = fresh.get_mut(entity) {
// Armed and reaped inside one frame (a flickering re-apply) — the staged set is
// the live one, so the removal has to land there too.
// Armed and reaped in one frame: the staged set is the live one.
reap(n, *spell_id, now);
trace_edge("reap", *entity, *spell_id, n);
}
@@ -553,8 +355,7 @@ pub(crate) fn drain_aura_procs(
}
}
/// Drop every node one spell installed (`0x5ff290`) and re-aim the ramp at the new target
/// (the next head node down, or the bare base).
/// Drop every node one spell installed (`0x5ff290`) and re-aim the ramp.
fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) {
n.alpha.retain(|(s, _)| *s != spell_id);
n.tint.retain(|(s, _)| *s != spell_id);
@@ -562,10 +363,8 @@ fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) {
n.retarget(now);
}
/// 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** (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()`.
/// Install one spell's nodes at the head of each list, replacing any it had: nodes are keyed by
/// spell id, so a second caster's copy of an aura installs one set.
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);
@@ -580,15 +379,7 @@ fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) {
n.retarget(now);
}
/// The layer's instrument (`WOW_MOVE_TRACE=<path>`, tag `aur`) — one line per node edge, carrying
/// 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
/// (`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
/// exactly that.
/// Trace (`WOW_MOVE_TRACE=<path>`, tag `aur`) each node edge: the spell, its nodes, the target.
fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) {
if !benilla_assets::trace::enabled() {
return;
@@ -603,7 +394,7 @@ fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) {
benilla_assets::trace::line(
"aur",
&format!(
"{what} e={entity} spell={spell_id} alpha={alphas:?}{tint}{rate} base {:.2} cur {:.2} target {:.2}",
"{what} e={entity} spell={spell_id} alpha={alphas:?}{tint}{rate} -> base {:.2} cur {:.2} target {:.2}",
n.base,
n.current,
n.target()
@@ -611,11 +402,8 @@ 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; 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).
/// `baseAlpha` (`0x60d2d0`): the current display row's `CreatureModelAlpha / 255`, players
/// included (Ghost Wolf's display 4613 reads 102); no row reads 1.0 (`0x60d2e4`).
fn base_alpha(
entity: Entity,
units: &Query<&crate::net::NetEntity>,
@@ -641,17 +429,11 @@ fn display_base_alpha(
}
}
/// Re-resolve every unit's base alpha when its display changes — the reference's DISPLAYID
/// 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
/// display swaps home. The 1000 ms cubic fade is part of the mechanism: a display swap always
/// eases to the new alpha, never snaps.
///
/// `Changed<NetEntity>` is our `0x60ae10`: [`crate::entities`]' live-display refresh writes
/// `net.display_id` only on an actual swap (and insertion covers the create path). The base
/// compare keeps a scale-only mutation from restarting anything.
/// Re-resolve a unit's base alpha when its display changes and ramp to it over the same 1000 ms,
/// as the reference's DISPLAYID watcher does (`0x604990` → `0x60abe0`, the row cached at
/// `0x60afb0`, → `0x60ad9f` → `0x60d180`): a display below 255 alpha is translucent with no aura.
/// `Changed<NetEntity>` stands for its record-change gate (`0x60ae10`); the base compare ignores a
/// scale-only change.
pub(crate) fn refresh_base_alpha(
time: Res<Time>,
creatures: Option<Res<crate::entities::Creatures>>,
@@ -675,9 +457,7 @@ pub(crate) fn refresh_base_alpha(
n.retarget(now);
}
}
// A unit that never carried a node set needs one exactly when its display is
// authored translucent — created settled at opaque so the retarget rides the same
// 1000 ms ramp down a fresh aura node would.
// A translucent display gets a node set, created at opaque so it ramps down.
None if base < 1.0 => {
debug!(
"aura_visual: e={entity} display {:?} authors base alpha {base:.2} (1 s ramp)",
@@ -693,10 +473,7 @@ pub(crate) fn refresh_base_alpha(
}
}
/// The fadeable-part view [`apply_aura_alpha`] authors through: the part's material pair and tag,
/// plus every other factor of its alpha (its animated colour alpha, its live appear/despawn ramp)
/// and the light law that decides which material variant it should be on. [`FadeMaterials`] is the
/// "this is a fadeable mesh" marker every writer of this channel keys on.
/// The fadeable parts [`apply_aura_alpha`] writes, with every other factor of their alpha.
type AuraParts<'w, 's> = Query<
'w,
's,
@@ -710,34 +487,17 @@ type AuraParts<'w, 's> = Query<
Has<PendingAppearFade>,
Has<benilla_world::model_render::FarSideOfWater>,
),
// Disjointness for the card pass (both want `&mut MeshTag`); a card never carries
// `FadeMaterials`, so this filter excludes nothing that would otherwise match — the same
// trick `apply_self_model_fade` uses for the same pair of queries.
// Disjoint from the card query, as both take `&mut MeshTag`; no card carries `FadeMaterials`.
Without<benilla_world::billboard::BillboardCard>,
>;
/// Drive every unit's aura-alpha ramp and author its parts' render alpha + material.
/// Drive every unit's aura-alpha ramp and write its parts' render alpha and material;
/// [`AuraNodes::authoring`] latches the release.
///
/// Ordered **after** the three steady-state authors of a unit part's alpha field (the interior
/// classifier, the visibility authority, `entities::apply_unit_mat_alpha`) and **before** the
/// self-avatar zoom feather, so it overrides them for exactly the units that carry a live aura
/// alpha, and the self feather stays the last word on your own body (it folds this factor in
/// itself — [`crate::player::apply_self_model_fade`]). This is the same override-then-release
/// protocol the feather uses (decision 0213); [`AuraNodes::authoring`] is its latch.
///
/// The product it writes is the reference's own: this unit's `ramped aura alpha × the part's
/// animated colour alpha × its live appear/despawn ramp`. Reading the fade back out of
/// [`RenderFade`] rather than the tag keeps every factor a pure function of state, so the write is
/// idempotent (running it twice can't compound) exactly as `apply_unit_mat_alpha`'s is.
///
/// **The material swap is load-bearing, not cosmetic.** A cutout/opaque batch ignores instance
/// alpha, so writing `0.3` into the tag without moving the part onto its **blend twin** renders no
/// translucency at all — the same "blend pass while `0 < fade < 1`" the reference runs for its
/// doodad fade ([`benilla_world::model_fade`]). It goes through [`FadeMaterials::material_for`], the one
/// place the blend-vs-law axes compose, so this writer and the classifier can never disagree.
/// Note the swap keys on the *instance* alpha only: a batch whose own animated colour alpha dips is
/// a per-batch quantity the reference combines separately, and forcing those onto a blend pass would
/// change every existing unit's look.
/// Each part gets the reference's product (`0x614baa` → `0x710cb0`, `0x707680`): the ramped aura
/// alpha × the batch's animated colour alpha × its appear/despawn ramp, recomputed from state so a
/// rerun cannot compound. A cutout or opaque batch ignores instance alpha, so a translucent unit's
/// parts move to their blend material, keyed on the instance alpha alone.
pub(crate) fn apply_aura_alpha(
time: Res<Time>,
mut commands: Commands,
@@ -759,14 +519,8 @@ pub(crate) fn apply_aura_alpha(
let now = time.elapsed_secs();
for (root, mut n, declared) in &mut roots {
let alpha = n.tick(now);
// Declare it to the engine, which owns the composed model alpha and its chain walk
// (`model_fade::ModelFade` — 1164's inversion). This is the whole of the aura's claim on
// the channel: a number and a reason, never a write.
//
// Written **before** the settled-opaque early-out below, and unconditionally on change:
// a declaration the engine keeps reading has to be retired by its declarer, and the ramp
// arriving at 1.0 is exactly when that matters. (`AuraNodes` itself is never removed — it
// parks at 1.0 — so there is no removal hook to hang this on.)
// Declared to the engine's composed model alpha (`model_fade::ModelFade`) before the
// settled-opaque early-out, so the ramp's arrival at 1.0 is declared too.
if declared.is_none_or(|d| d.0 != alpha) {
commands
.entity(root)
@@ -776,8 +530,7 @@ pub(crate) fn apply_aura_alpha(
if !translucent && !n.authoring {
continue; // settled opaque: not ours, nothing to release
}
// The walked set doubles as "which anchors belong to this unit" for the card pass below —
// built only for a unit we're authoring, so a quiet world never pays for it.
// The walked set tells the card pass which anchors are this unit's.
let mut walked = bevy::ecs::entity::EntityHashSet::default();
author_descendants(
root,
@@ -790,23 +543,14 @@ pub(crate) fn apply_aura_alpha(
&mut walked,
);
author_cards(alpha, &walked, &mut cards);
// The release frame is the one where we authored the settled value and handed the material
// back; after it this unit costs a ramp tick and the branch above.
// A frame written at opaque was the release; the latch drops after it.
n.authoring = translucent;
}
}
/// Fold the unit's aura alpha into its **billboard cards** — the batches that can't be tree children.
///
/// An M2's billboard batches are world ROOT entities that merely *follow* an anchor inside the model
/// (decision 0153: their mesh is centred on the bone pivot and their transform belongs to the
/// billboard system), so a descendant walk cannot see them. Skipping them is a burned trap, not a
/// hypothetical: the night-elf eye glow — two additive `…EYEGLOW.BLP` quads at head height — went on
/// burning in mid-air after the body it belongs to had faded out (ledger B71), and a stealthed night
/// elf with full-brightness eyes is the same bug wearing this system's clothes. Cards are picked up
/// by the same rule the self-avatar feather uses: test the card's follow-anchor against the walked
/// set. One multiply covers both halves — the card dims with the body, and for an ADD blend the
/// shader's `out_rgb *= faded_alpha` takes a low alpha toward gone.
/// Fold the unit's aura alpha into its billboard cards, world-root entities that follow an anchor
/// in the model and so escape the descendant walk (a stealthed night elf's eye glow would stay
/// lit). A card is the unit's when its follow anchor is in `walked`.
fn author_cards(
alpha: f32,
walked: &bevy::ecs::entity::EntityHashSet,
@@ -820,9 +564,8 @@ fn author_cards(
if !card.follows().is_some_and(|a| walked.contains(&a)) {
continue;
}
// Compose from the animation's own factor rather than from the tag we'd read back, so the
// card's per-sequence alpha animation stays alive under the aura — and so the release frame's
// `alpha = 1` write lands exactly on the value the card would have had anyway.
// From the animation's own factor, not the tag, so the card's alpha animation survives and
// the release lands on the card's own value.
let authored = anim.map_or(1.0, |a| a.current);
let bits = benilla_world::mesh_tag::with_alpha(tag.0, authored * alpha);
if tag.0 != bits {
@@ -831,12 +574,8 @@ fn author_cards(
}
}
/// Depth-first author for [`apply_aura_alpha`]: apply to `entity` if it is a fadeable part, then
/// recurse regardless — a joint or an attach-model root carries no [`FadeMaterials`] itself but the
/// held weapon / helm / shoulder meshes hang beneath it, and the reference's alpha is a per-CGUnit
/// property that its attached models render off (the same reason
/// [`benilla_world::model_fade::apply_despawn_fade`] and the self feather both walk descendants, not
/// children).
/// Write `entity` if it is a fadeable part, then recurse regardless: held items, helms and
/// shoulders hang under joints that carry no [`FadeMaterials`].
fn author_descendants(
entity: Entity,
alpha: f32,
@@ -849,14 +588,10 @@ fn author_descendants(
) {
walked.insert(entity);
if let Ok((fm, mut tag, mut mat, anim, lit, fade, pending, far_side)) = parts.get_mut(entity) {
// A part still WAITING on its appear-fade is deliberately invisible until the ramp arms (its
// tag alpha is seeded near-zero at spawn), so authoring it would flash a streaming-in unit's
// geometry at the aura alpha for the pending window. Every other author of this channel
// filters the same way. Skip the write, still recurse — a joint's children can be past it.
// A part awaiting its appear-fade stays invisible until the ramp arms; writing it would
// flash it at the aura alpha.
if !pending {
// Every live factor on this part, recomputed from state (never read back from the tag),
// so the write is idempotent: the unit's ramped aura alpha × the batch's animated colour
// alpha × the part's own live appear/despawn ramp.
// Recomputed from state, never read back from the tag, so the write is idempotent.
let ramp = fade.map_or(1.0, |f| {
let t = if f.duration > 0.0 {
(now - f.started) / f.duration
@@ -870,9 +605,7 @@ fn author_descendants(
if tag.0 != bits {
tag.0 = bits;
}
// The INSTANCE alpha decides the pass; a settled-opaque release hands the law's steady
// material back and asks the classifier to re-author, so nothing is left on a stale
// one. The water-plane axis composes here like everywhere else (`far_resolved`).
// The instance alpha alone picks the pass; the far-side-of-water axis composes on top.
let want = benilla_world::model_render::far_resolved(
fm.material_for(lit, alpha < 1.0),
far_side,
@@ -882,8 +615,7 @@ fn author_descendants(
if mat.0 != want {
mat.0 = want;
if alpha >= 1.0 && lit.is_some() {
// 0734's queue: let the classifier re-assert this part's full payload (probe
// slot / fog bit) over whatever this episode wrote.
// The released part's probe slot and fog bit are the interior classifier's.
reauthor.0.push(entity);
}
}
@@ -905,9 +637,7 @@ fn author_descendants(
}
}
/// This unit's live aura alpha, for the writers that compose it into their own product rather than
/// deferring to [`apply_aura_alpha`] (the self-avatar feather, which runs after it and wins on the
/// self body). `1.0` when the unit carries no aura nodes.
/// A unit's live aura alpha, for the self-avatar feather, which composes it into its own product.
pub(crate) fn root_alpha(nodes: Option<&AuraNodes>) -> f32 {
nodes.map_or(1.0, |n| n.current)
}
+47 -125
View File
@@ -1,7 +1,5 @@
//! Tests for the aura-state CharProc layer. The chains are the **real 5875 rows** (read off
//! `SpellVisualKit.dbc` this session with `benilla-extract charprocs`), so a schema or column-order
//! regression in the data layer shows up here as a wrong number rather than as a silent nothing —
//! which is precisely how B114 hid.
//! Tests for the aura-state CharProc layer over shipped kit rows (`benilla-extract charprocs`), so
//! a schema or column-order regression shows as a wrong number, not a silent nothing.
use std::collections::HashMap;
@@ -13,20 +11,17 @@ use benilla_protocol::messages::ObjectFields;
use super::*;
use crate::creature_anim::SpellVisuals;
// ── The real chains, from the shipped table ───────────────────────────────────────────────────────
// ── The shipped chains ───────────────────────────────────────────────────────────────────────────
/// Stealth rank 1 → `SpellVisual` 184 → state kit 312: proc 7 (unmodelled) + **proc 14 @ 0.3**, no
/// effect models, no anim. The whole of B114 in one row.
/// Stealth rank 1 → `SpellVisual` 184 → state kit 312: proc 7 and proc 14 at 0.3, nothing else.
const STEALTH: u32 = 1784;
const STEALTH_VISUAL: u32 = 184;
const STEALTH_KIT: u32 = 312;
/// The ghost aura → visual 886 → kit 989: proc 1 tint `9222653.0` (= `0x8CB9FD`) + proc 14 @ 0.5.
/// The ghost aura → visual 886 → kit 989: proc 1 tint `9222653.0` (`0x8CB9FD`) and proc 14 at 0.5.
const GHOST: u32 = 8326;
const GHOST_VISUAL: u32 = 886;
const GHOST_KIT: u32 = 989;
/// **Ice Block** → `SpellVisual` 4325 → state kit 3709: proc 1 tint `9074175.0` + **proc 11 @ 0.0**
/// (plus `icebarrier_state.mdx` at attach 0x13, which this layer doesn't own). The freeze, from the
/// shipped table — `benilla-extract spellvis 11958`.
/// Ice Block → `SpellVisual` 4325 → state kit 3709: proc 1 tint `9074175.0` and proc 11 at 0.0.
const ICE_BLOCK: u32 = 11958;
const ICE_BLOCK_VISUAL: u32 = 4325;
const ICE_BLOCK_KIT: u32 = 3709;
@@ -38,7 +33,6 @@ fn proc(ty: i32, param0: f32) -> CharProc {
}
}
/// A kit carrying `procs` in slot order and nothing else — the shape of every proc-only state kit.
fn proc_kit(procs: &[CharProc]) -> VisualKit {
let mut slots = [None; KIT_CHAR_PROCS];
for (slot, p) in slots.iter_mut().zip(procs) {
@@ -52,8 +46,7 @@ fn proc_kit(procs: &[CharProc]) -> VisualKit {
// ── The dispatch ─────────────────────────────────────────────────────────────────────────────────
/// [`node_for`] is the twin of the client's proc jump table: 14 → an alpha node carrying `params[0]`,
/// 1 → a tint node with the packed colour unpacked, everything else → no node (and so no arm).
/// [`node_for`] follows the client's proc jump table: 14 alpha, 1 tint, 11 rate, else no node.
#[test]
fn the_dispatch_names_only_the_verified_procs() {
assert_eq!(
@@ -66,20 +59,17 @@ 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, 0xFF8CB9FD once the client ORs the opaque
// byte on (`0x60d8cc`).
// 9222653 is 0x8CB9FD, the ghost's blue-white; the client ORs on the opaque byte (`0x60d8cc`).
assert_eq!(
node_for(proc(char_proc_type::TINT, 9_222_653.0)),
Some(AuraNode::Tint([0x8C, 0xB9, 0xFD])),
);
// 65280 = 0x00FF00 — the poison state kit's pure green.
// 65280 is 0x00FF00, the poison state kit's green.
assert_eq!(
node_for(proc(char_proc_type::TINT, 65_280.0)),
Some(AuraNode::Tint([0x00, 0xFF, 0x00])),
);
// Proc 11 is a playback RATE, straight through (`0x60db7e` → `SetBoneAnimSpeed 0x712910`).
// Ice Block's 0.0 is the freeze; kit 1744's 8947848.0 goes through unexamined too, exactly as
// the reference hands the column on.
// Proc 11's rate passes on unexamined (`0x60db7e` → `SetBoneAnimSpeed` `0x712910`).
assert_eq!(
node_for(proc(char_proc_type::ANIM_RATE, 0.0)),
Some(AuraNode::AnimRate(0.0)),
@@ -89,8 +79,7 @@ fn the_dispatch_names_only_the_verified_procs() {
node_for(proc(char_proc_type::ANIM_RATE, 8_947_848.0)),
Some(AuraNode::AnimRate(8_947_848.0)),
);
// The four other types that ride real state kits have no verified mechanism yet: no node, so
// nothing is invented and the kit doesn't arm on them alone.
// Types on shipped state kits whose mechanism is untraced get no node, so none arms a kit.
for ty in [2, 7, 8, 13] {
assert_eq!(node_for(proc(ty, 1.0)), None, "type {ty} is unmodelled");
}
@@ -98,10 +87,7 @@ 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.
/// 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.
/// `0x60d180`: the target is `baseAlpha` times the head (newest) node's factor alone.
#[test]
fn the_target_is_base_times_the_newest_node() {
let mut n = AuraNodes::new(1.0);
@@ -116,13 +102,11 @@ fn the_target_is_base_times_the_newest_node() {
"the newest node is the term"
);
// Reaping the head hands the term back to the older node.
n.alpha.retain(|(s, _)| *s != GHOST);
n.retarget(0.0);
assert!((n.target() - 0.3).abs() < 1e-6);
// A display authored translucent (CreatureModelAlpha 128/255) multiplies the head term —
// and stands alone when no node is live (the display-set leg, no aura anywhere).
// A translucent display's base (128/255) scales the head term, or stands alone with no node.
let mut half = AuraNodes::new(1.0);
half.base = 128.0 / 255.0;
install(&mut half, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
@@ -132,9 +116,7 @@ fn the_target_is_base_times_the_newest_node() {
assert!((half.target() - 128.0 / 255.0).abs() < 1e-6);
}
/// 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 (the fade, `0x614f80`, is part of the mechanism).
/// A display swap's base change ([`refresh_base_alpha`]) rides the same ramp (`0x614f80`).
#[test]
fn a_base_change_rides_the_same_ramp() {
let wolf = 102.0 / 255.0;
@@ -148,7 +130,6 @@ fn a_base_change_rides_the_same_ramp() {
);
assert!(n.translucent());
// The swap home: base back to 1.0, eased up from the live value.
n.base = 1.0;
n.retarget(AURA_ALPHA_FADE_SECS);
assert!((n.from - wolf).abs() < 1e-6);
@@ -156,9 +137,7 @@ fn a_base_change_rides_the_same_ramp() {
assert!(!n.translucent(), "settled opaque again");
}
/// A re-applied aura installs ONE node set, not a second copy — the reference keys its nodes by spell
/// id (`node+0x18`) and its own dedup walk returns early on a spell that already has one. Without
/// this a refresh would square the factor every application.
/// Nodes are keyed by spell id (`node+0x18`): a re-applied aura replaces its node.
#[test]
fn re_arming_the_same_spell_replaces_its_node() {
let mut n = AuraNodes::new(1.0);
@@ -168,17 +147,14 @@ fn re_arming_the_same_spell_replaces_its_node() {
assert!((n.target() - 0.3).abs() < 1e-6);
}
/// The ramp is `StartAlphaFade(target, 1000 ms)` on the `clamp01(t)³` ease, in **both** directions:
/// arming eases down to the product, reaping eases back up — and a retarget mid-ramp starts from
/// where the ramp actually is, never from the old endpoint (a stealth dropped 300 ms into the fade
/// must not jump to 0.3 first).
/// `StartAlphaFade(target, 1000 ms)` eased `clamp01(t)³` both ways, retargeted from the live value.
#[test]
fn the_ramp_eases_over_one_second_both_ways() {
let mut n = AuraNodes::new(1.0);
install(&mut n, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
assert!((n.tick(0.0) - 1.0).abs() < 1e-6, "t=0 is still opaque");
// t³ at the halfway mark = 0.125 of the way down.
// t³ at the halfway mark is 0.125 of the way down.
let half = n.tick(0.5);
assert!(
(half - (1.0 + (0.3 - 1.0) * 0.125)).abs() < 1e-6,
@@ -191,7 +167,6 @@ fn the_ramp_eases_over_one_second_both_ways() {
assert!((n.tick(5.0) - 0.3).abs() < 1e-6, "and clamps past the end");
assert!(n.translucent());
// The aura drops: ease back to the new product from where we are.
n.alpha.clear();
n.retarget(AURA_ALPHA_FADE_SECS);
assert!((n.from - 0.3).abs() < 1e-6, "ramps from the live value");
@@ -199,7 +174,6 @@ fn the_ramp_eases_over_one_second_both_ways() {
assert!((n.tick(2.0 * AURA_ALPHA_FADE_SECS) - 1.0).abs() < 1e-6);
assert!(!n.translucent(), "settled opaque again");
// A mid-ramp retarget picks up the live value, not the endpoint.
let mut m = AuraNodes::new(1.0);
install(&mut m, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
let mid = m.tick(0.7);
@@ -208,8 +182,6 @@ fn the_ramp_eases_over_one_second_both_ways() {
assert!((m.from - mid).abs() < 1e-6);
}
/// An aura refresh whose factor doesn't change must not restart the ease — otherwise a buff ticking
/// every few seconds would keep re-easing a settled body.
#[test]
fn a_retarget_to_the_same_value_does_not_restart_the_ease() {
let mut n = AuraNodes::new(1.0);
@@ -221,8 +193,7 @@ fn a_retarget_to_the_same_value_does_not_restart_the_ease() {
assert!((n.tick(10.0) - 0.3).abs() < 1e-6, "and stays arrived");
}
/// Proc 1's nodes link at the head like the alpha list's (`unit+0xce0`), so the tint the per-frame
/// apply reads is the NEWEST aura's — deterministic under stacking.
/// Proc 1's nodes link at the head of `unit+0xce0`, so the newest aura's tint applies.
#[test]
fn the_tint_head_node_is_the_newest_installed() {
let mut n = AuraNodes::new(1.0);
@@ -230,15 +201,13 @@ fn the_tint_head_node_is_the_newest_installed() {
install(&mut n, GHOST, &[AuraNode::Tint([0x8C, 0xB9, 0xFD])], 0.0);
install(&mut n, 12881, &[AuraNode::Tint([0x00, 0xFF, 0x00])], 0.0);
assert_eq!(n.head_tint(), Some([0x00, 0xFF, 0x00]));
// The newest dropping hands the head back.
n.tint.retain(|(s, _)| *s != 12881);
assert_eq!(n.head_tint(), Some([0x8C, 0xB9, 0xFD]));
assert_eq!(n.target(), 1.0, "a tint node is not an alpha term");
}
// ── The watcher → drain edge, over the real Stealth chain ─────────────────────────────────────────
// ── The watcher → drain edge ─────────────────────────────────────────────────────────────────────
/// A fixture app carrying the two real chains, the aura watcher and the drain.
fn app_with_chains() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
@@ -247,7 +216,6 @@ fn app_with_chains() -> App {
app.add_message::<crate::creature_anim::SpellKitSound>();
app.add_message::<AuraProc>();
app.add_message::<crate::creature_anim::BaseAnimRecompute>();
// The water-plane twin map the alpha author composes with (empty here — no water in a fixture).
app.init_resource::<benilla_world::model_render::FarSideTwins>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([
@@ -274,8 +242,7 @@ fn app_with_chains() -> App {
),
]),
HashMap::from([
// Kit 312's real slots: the unmodelled proc 7 FIRST, then proc 14 — so this also pins
// that an unmodelled proc ahead of a modelled one doesn't shadow it.
// Kit 312's shipped order: the unmodelled proc 7 must not shadow proc 14 after it.
(
STEALTH_KIT,
proc_kit(&[proc(7, 1.0), proc(char_proc_type::ALPHA, 0.3)]),
@@ -333,8 +300,6 @@ fn app_with_chains() -> App {
app
}
/// The arming world plus the per-instance tint channel (decision 0812): the table resource, the
/// palette allocator whose slots index it, and the publish system.
fn app_with_tint_channel() -> App {
let mut app = app_with_chains();
app.init_resource::<benilla_world::instance_tint::InstanceTints>();
@@ -343,14 +308,12 @@ fn app_with_tint_channel() -> App {
app
}
/// **B114, end to end at the mechanism.** Stealth's state kit carries no effect models at all, so the
/// effects-only watcher never armed it and the character showed nothing. Now the same slot edge
/// installs a proc-14 node and the unit's target alpha is 0.3 — and dropping the aura ramps it back.
/// Stealth's proc-only state kit (no effect models) still installs its proc-14 node.
#[test]
fn a_proc_only_state_kit_arms_stealth_translucency() {
let mut app = app_with_chains();
// The aura lands in slot 0 (UNIT_FIELD_AURA[0] = field 47) with an occupied AURAFLAGS nibble.
// The aura lands in slot 0 (UNIT_FIELD_AURA[0], field 47) with an occupied AURAFLAGS nibble.
let stealthed = ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)]);
let unit = app
.world_mut()
@@ -369,7 +332,6 @@ fn a_proc_only_state_kit_arms_stealth_translucency() {
assert!((n.target() - 0.3).abs() < 1e-6);
assert!(n.head_tint().is_none(), "kit 312 carries no tint");
// The aura drops: the node goes and the target ramps back to opaque.
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
@@ -382,8 +344,7 @@ fn a_proc_only_state_kit_arms_stealth_translucency() {
assert!((n.target() - 1.0).abs() < 1e-6);
}
/// The ghost aura's kit carries both procs: the tint node lands beside the alpha one off a single
/// slot edge (the dispatcher walks all four slots), and the alpha is the ghost's 0.5.
/// One slot edge installs both of the ghost kit's nodes: the dispatcher walks all four proc slots.
#[test]
fn a_kit_with_both_procs_installs_both_nodes() {
let mut app = app_with_chains();
@@ -401,8 +362,7 @@ fn a_kit_with_both_procs_installs_both_nodes() {
assert_eq!(n.head_tint(), Some([0x8C, 0xB9, 0xFD]));
}
/// Two auras on one unit: the newest node holds the head (the reference's link-at-head list), and
/// reaping the other leaves it untouched — the body never snaps opaque while any alpha aura lives.
/// The newest node holds the head; reaping the older aura leaves it.
#[test]
fn two_auras_stack_and_unstack_through_the_slots() {
let mut app = app_with_chains();
@@ -433,8 +393,6 @@ fn two_auras_stack_and_unstack_through_the_slots() {
assert!((n.target() - 0.5).abs() < 1e-6);
}
/// A state kit whose every proc is unmodelled (and which carries no effect models either) must not
/// arm: no node set, so nothing claims the unit's alpha channel on a mechanism we haven't verified.
#[test]
fn an_unmodelled_proc_only_kit_arms_nothing() {
const SAP: u32 = 6770;
@@ -450,7 +408,7 @@ fn an_unmodelled_proc_only_kit_arms_nothing() {
..Default::default()
},
)]),
// Kit 691's real content: proc 7 @ 2.0 and nothing else.
// Kit 691's shipped content: proc 7 at 2.0 and nothing else.
HashMap::from([(SAP_KIT, proc_kit(&[proc(7, 2.0)]))]),
)));
app.insert_resource(crate::ui_action::Spells {
@@ -480,12 +438,8 @@ fn an_unmodelled_proc_only_kit_arms_nothing() {
// ── The render authoring ─────────────────────────────────────────────────────────────────────────
/// The author owns a translucent unit's parts **through the whole descendant tree** (a held weapon
/// hangs off a joint several levels down, and the reference's alpha is a per-CGUnit property its
/// attached models render off), writes the product into the tag's alpha field, and moves the part
/// onto its blend twin — without which a cutout batch ignores the alpha entirely and B114's symptom
/// survives the fix. On the frame the ramp latches opaque it releases: settled alpha, steady
/// material, and the part re-queued for the classifier.
/// The author writes the alpha down the whole tree (the reference's alpha is per CGUnit) and moves
/// each part to its blend twin, as a cutout batch ignores alpha; at opaque it releases.
#[test]
fn the_author_owns_the_tree_then_releases_at_opaque() {
use benilla_world::model_fade::FadeMaterials;
@@ -499,7 +453,6 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<benilla_world::interior::InteriorReauthor>();
// The water-plane twin map the author composes with (empty — no water in a fixture).
app.init_resource::<benilla_world::model_render::FarSideTwins>();
app.add_systems(Update, apply_aura_alpha);
@@ -533,8 +486,7 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
))
.id();
// Run the ramp out to its end: `MinimalPlugins`' clock advances a hair per update, so drive the
// component's own clock instead of guessing frame counts.
// `MinimalPlugins`' clock barely advances per update, so the ramp is ended on its own clock.
app.update();
{
let mut root_mut = app.world_mut().entity_mut(root);
@@ -560,8 +512,7 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
);
}
// The aura drops and the ramp completes: the release frame restores opacity and the steady
// material, and stops authoring.
// The aura drops and the ramp completes: the release frame restores opacity and material.
{
let mut root_mut = app.world_mut().entity_mut(root);
let mut n = root_mut.get_mut::<AuraNodes>().unwrap();
@@ -593,11 +544,10 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
);
}
// ── The tint's render channel (decision 0812) ─────────────────────────────────────────────────────
// ── The tint's render channel ─────────────────────────────────────────────────────
/// **The tint end to end**: the ghost aura's CharProc-1 head node lands in the per-instance table at
/// the unit's own rig slot, packed the way the reference packs it (`param | 0xff000000`) — and the
/// aura dropping puts the slot back to identity, so the body goes back to its own colours.
/// The ghost's tint lands at the unit's rig slot packed as `param | 0xff000000`, and clears with
/// the aura.
#[test]
fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() {
let mut app = app_with_tint_channel();
@@ -632,8 +582,7 @@ fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() {
"the ghost's pale blue-white, alpha byte and all"
);
// The aura leaves its slot: the reference drops the tint node (`0x5ff320`) with no ease, and the
// channel goes back to identity — which is the shader's no-op word, not a colour.
// Dropped with no ease (`0x5ff320`); identity is the shader's no-op word, not a colour.
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
@@ -647,12 +596,8 @@ fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() {
assert_eq!(tints.get(slot), benilla_world::instance_tint::IDENTITY);
}
/// **The chain (decision 0841).** A rigged ATTACHED model — a spell-effect instance, or one of the
/// seven shoulder models whose welded billboard geometry made the item lane rig — carries its own
/// instance slot, because the vertex stage indexes the skin palette with that same field and cannot
/// borrow the wearer's. Its tint therefore has to come up the `ParentModel` link, which is the
/// reference's own route for an attached model's colours (`0x714000`). Without this walk, adding the
/// rig would have un-tinted exactly the pauldrons it was added for.
/// A rigged attached model has its own instance slot, which the vertex stage indexes the palette
/// by, so its tint comes up the `ParentModel` chain, as the reference composes it (`0x714000`).
#[test]
fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() {
let mut app = app_with_tint_channel();
@@ -697,7 +642,6 @@ fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() {
"and so is the pauldron riding it"
);
// The aura drops: both ends of the chain go back to identity, not just the unit's own slot.
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
@@ -715,9 +659,6 @@ fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() {
);
}
/// An unchained rig — a placed doodad, a world effect — has no parent to inherit from and must stay
/// its own colour. The walk's terminating case, and the guard against a chain lookup that
/// "helpfully" falls back to some other unit's tint.
#[test]
fn an_unchained_rig_inherits_nothing() {
let mut app = app_with_tint_channel();
@@ -754,9 +695,7 @@ fn an_unchained_rig_inherits_nothing() {
);
}
/// A tinted unit going away must not leave its colour on the slot for whoever allocates it next —
/// the leak that would paint an unrelated creature ghost-blue. Covered structurally by `RigSkin`'s
/// free hook, so it holds for a despawn AND for a gear/display rebuild that replaces the component.
/// `RigSkin`'s free hook clears the tint on a despawn or a component swap, before any reuse.
#[test]
fn the_rig_free_hook_clears_a_dead_units_tint() {
let mut app = app_with_tint_channel();
@@ -787,7 +726,7 @@ fn the_rig_free_hook_clears_a_dead_units_tint() {
0xff8c_b9fd,
);
// Despawn without any aura edge — the unit streamed out mid-buff, which is the common case.
// Despawn with no aura edge: the unit streamed out mid-buff.
app.world_mut().entity_mut(unit).despawn();
assert_eq!(
app.world()
@@ -800,16 +739,12 @@ fn the_rig_free_hook_clears_a_dead_units_tint() {
// ── The freeze (proc 11) ─────────────────────────────────────────────────────────────────────────
/// The arming world plus the clock-hold leg — the third proc's apply, ordered after the drain the
/// way the real schedule orders it after the drain *and* the driver.
fn app_with_freeze() -> App {
let mut app = app_with_chains();
app.add_systems(Update, apply_aura_anim_rate.after(drain_aura_procs));
app
}
/// A rig mid-animation: two clips playing at the speeds the driver picked for them (a gait scaled to
/// the mover's speed, and a cast one-shot at 1.0 — the pair Ice Block catches).
fn rig_playing(speeds: &[(u32, f32)]) -> AnimationPlayer {
let mut player = AnimationPlayer::default();
for (node, speed) in speeds {
@@ -820,8 +755,7 @@ fn rig_playing(speeds: &[(u32, f32)]) -> AnimationPlayer {
player
}
/// `(paused, speed)` of one armed clip — the two things the freeze is judged on: the clock is held,
/// and the *value* other code copies is untouched.
/// `(paused, speed)` of one armed clip: the freeze holds the clock and leaves the speed.
fn clip(app: &App, rig: Entity, node: u32) -> (bool, f32) {
let a = app
.world()
@@ -833,10 +767,7 @@ fn clip(app: &App, rig: Entity, node: u32) -> (bool, f32) {
(a.is_paused(), a.speed())
}
/// **The director's retest, at the mechanism.** Ice Block's state kit carries proc 11 at rate 0, so
/// the unit's clocks stop dead: the run cycle it was mid-stride in and the cast one-shot that had
/// just been armed both hold the frame they were on — which is why the caster never gets to raise
/// their hand. Dropping the aura lets both go again, at the speeds the driver gave them.
/// Ice Block's proc 11 at rate 0 holds the gait and the cast one-shot; the aura's end frees them.
#[test]
fn ice_block_holds_the_clocks_and_the_drop_lets_them_go() {
let mut app = app_with_freeze();
@@ -854,18 +785,16 @@ fn ice_block_holds_the_clocks_and_the_drop_lets_them_go() {
let n = app.world().entity(unit).get::<AuraNodes>().unwrap();
assert_eq!(n.head_anim_rate(), Some(0.0), "kit 3709's proc 11 is 0");
// 9074175 = 0x8A75FF — the ice-blue the same kit tints the body with, beside the freeze.
// 9074175 is 0x8A75FF, the same kit's ice-blue tint.
assert_eq!(n.head_tint(), Some([0x8A, 0x75, 0xFF]), "kit 3709's proc 1");
assert!(clip(&app, unit, 4).0, "the gait stops mid-stride");
assert!(clip(&app, unit, 54).0, "so does the cast one-shot");
// The SPEEDS are untouched, which is the whole point: nothing that copies a clip's rate
// (`transplant_up` does, to move a one-shot onto the torso overlay) can inherit the freeze.
// The speeds are untouched, so `transplant_up`, copying a clip's rate, never inherits a freeze.
assert_eq!(clip(&app, unit, 4).1, 1.35);
assert_eq!(clip(&app, unit, 54).1, 1.0);
app.update(); // idempotent
// The aura leaves the slots.
crate::net::apply_fields_for_test(
app.world_mut(),
unit,
@@ -889,12 +818,8 @@ fn ice_block_holds_the_clocks_and_the_drop_lets_them_go() {
);
}
/// **The bug the first shipped version had, pinned so it cannot come back.** A one-shot moved to the
/// torso overlay under the freeze (`transplant_up` copies the source clip's speed and seek) used to
/// be saved at the frozen `0`, restored to `0`, and left welded to the upper body for the rest of the
/// session — surviving every later cast, because a clip that never advances never *finishes* and so
/// never releases the overlay. Pausing carries no value to copy: the transplant inherits the driver's
/// real speed, and the thaw sets it running.
/// A one-shot moved to the torso overlay under the freeze copies its source's speed, which the
/// pause leaves real, so the thaw runs it out; a copied 0 would never finish or free the overlay.
#[test]
fn a_clip_armed_under_the_freeze_comes_back_at_its_own_speed() {
let mut app = app_with_freeze();
@@ -946,8 +871,7 @@ fn a_clip_armed_under_the_freeze_comes_back_at_its_own_speed() {
);
}
/// The mount comes with the rider: `0x6201d0` writes `[unit+0xdc]` before it writes `[unit+0xd8]`,
/// so a frozen rider's mount stops under them rather than running on the spot.
/// `0x6201d0` writes the mount `[unit+0xdc]` before the body `[unit+0xd8]`: the mount freezes too.
#[test]
fn the_freeze_reaches_the_mount_body() {
let mut app = app_with_freeze();
@@ -982,8 +906,7 @@ fn the_freeze_reaches_the_mount_body() {
assert_eq!(clip(&app, mount, 5).1, 2.0);
}
/// An aura with no proc 11 — Stealth — leaves the clocks entirely alone. The freeze is the kit's,
/// never "any aura", and never the stun flag: nothing in the `0x40000` census touches animation.
/// The freeze is proc 11's alone, never any aura's and never the stun flag's (`0x40000`).
#[test]
fn an_aura_without_the_proc_never_touches_a_clock() {
let mut app = app_with_freeze();
@@ -1002,9 +925,8 @@ fn an_aura_without_the_proc_never_touches_a_clock() {
assert!(app.world().entity(unit).get::<AnimRateFreeze>().is_none());
}
/// Kit 1744's `8947848.0` is a rate our f32-seconds clock cannot express (the reference's is integer
/// ms with a modulo, where it is noise rather than a freeze). It installs a node — the data is read,
/// not dropped — and the apply leaves the clocks alone rather than inventing a look for it.
/// The one shipped non-zero rate, `8947848.0`, installs a node and holds no clock
/// ([`apply_aura_anim_rate`]).
#[test]
fn a_non_zero_rate_installs_a_node_and_holds_nothing() {
let mut n = AuraNodes::new(1.0);
File diff suppressed because it is too large Load Diff
+44 -141
View File
@@ -1,19 +1,10 @@
//! The chord codec (decision 0997): Bevy's physical input ↔ the reference's canonical binding
//! strings — `[ALT-][CTRL-][SHIFT-]<TOKEN>`, where the token set is 1.12's own (`W`, `SPACE`,
//! `NUMPAD0`, `BUTTON4`, `MOUSEWHEELUP`, the bare punctuation characters). These strings are what
//! the table stores, the window displays (through the `KEY_*` GlobalStrings), and the files save.
//! The chord codec: Bevy input to and from the 1.12 binding strings `[ALT-][CTRL-][SHIFT-]<TOKEN>`,
//! with 1.12's own token set. These strings are what the table stores, the window shows and the
//! files save; a press matches by equality, then once more with its leftmost modifier dropped.
//!
//! A press is matched against them by string equality — and then, on a miss, **once more** with
//! its leftmost modifier dropped ([`Chord::fallback`], decision 1142; this is the half 0585 got
//! wrong and 0997 carried).
//!
//! Prefix order is ALT-CTRL-SHIFT, verified from 1.12's own capture Lua (`Blizzard_BindingUI.lua`
//! prepends SHIFT, then CTRL, then ALT), its saved cache (`CTRL-SHIFT-PAGEDOWN`), and the engine's
//! own emitter (`0x4b6630` walks the `{bitIndex,name}` table at `0x846bd0` downward). It is not
//! only cosmetic: that order is what decides *which* modifier the fallback drops.
//!
//! The Super/Cmd key is **not** a 1.12 binding modifier: a chord never carries it, a super-modified
//! press never matches (0585's `sup` addition), and a capture with Super held is ignored outright.
//! Prefix order is ALT-CTRL-SHIFT (`Blizzard_BindingUI.lua:176-182`; the emitter `0x4b6630` walks
//! the table at `0x846bd0`), and it decides which modifier the fallback drops. Super/Cmd is not a
//! 1.12 modifier: a chord never carries it and a press with it held never matches.
use bevy::input::mouse::MouseButton;
use bevy::prelude::KeyCode;
@@ -27,8 +18,7 @@ pub(crate) enum BindKey {
WheelDown,
}
/// One parsed binding chord: the modifier set + the base input. Equality is how a press is
/// probed; [`Chord::fallback`] is the second and last probe when that misses.
/// One parsed binding chord: the modifier set and the base input, matched by equality.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) struct Chord {
pub alt: bool,
@@ -38,9 +28,8 @@ pub(crate) struct Chord {
}
impl Chord {
/// Parse a canonical chord string (`"ALT-CTRL-SHIFT-F1"`, `"CTRL--"` = Ctrl+minus). Unknown
/// base tokens return `None` — the table may hold them (a future command's key), the
/// dispatcher just can't press them.
/// Parse a canonical chord string (`"CTRL--"` is Ctrl+minus); an unknown base token is `None`,
/// held in the table but never pressable.
pub(crate) fn parse(s: &str) -> Option<Chord> {
let (mut alt, mut ctrl, mut shift) = (false, false, false);
let mut rest = s;
@@ -66,20 +55,10 @@ impl Chord {
})
}
/// The **one** retry the reference allows after an exact miss — drop the leftmost modifier
/// present, in the emitted prefix order ALT → CTRL → SHIFT. `None` once there is none left,
/// which is where the lookup ends (decision 1142).
///
/// `CBindings::ExecuteBinding` (`0x4b7990`) does this by string surgery: on a miss it calls
/// `strchr(chord, '-')` (`0x4b7a2b`/`0x4b7a2d`) and re-probes the text after the **first**
/// `'-'` (`0x4b7a49 inc eax`). Both second-probe misses land on `0x4b7b41 xor eax,eax` —
/// there is no third probe and no loop, so `ALT-CTRL-Z` reaches `CTRL-Z` and stops without
/// ever seeing `ALT-Z` or bare `Z`. Hence `Option`, not an iterator: the chain is two long.
///
/// Cutting at the first `'-'` *is* dropping the leftmost prefix, byte for byte — including
/// for the one token that contains a `'-'` of its own, the minus key. With a modifier held
/// the first `'-'` still terminates that prefix (`SHIFT--` → `-`); bare, the reference's
/// retry lands on the empty string and can only miss, which is exactly the `None` here.
/// The one retry after an exact miss: drop the leftmost modifier (ALT, then CTRL, then SHIFT).
/// `0x4b7990` re-probes the text after the first `'-'` (`0x4b7a2b`-`0x4b7a49`) and never
/// again (`0x4b7b41`), so `ALT-CTRL-Z` reaches `CTRL-Z` and stops. A bare `-` retries the
/// empty string there and misses, the `None` here.
pub(crate) fn fallback(self) -> Option<Chord> {
if self.alt {
Some(Chord { alt: false, ..self })
@@ -99,8 +78,7 @@ impl Chord {
}
}
/// Build the canonical chord string from live modifier state + a base token — the capture arm's
/// output (prefix order ALT-CTRL-SHIFT, the 1.12 canon).
/// Build the canonical chord string from live modifier state and a base token.
pub(crate) fn chord_string(alt: bool, ctrl: bool, shift: bool, token: &str) -> String {
let mut s = String::new();
if alt {
@@ -116,12 +94,8 @@ pub(crate) fn chord_string(alt: bool, ctrl: bool, shift: bool, token: &str) -> S
s
}
/// The 1.12 token for a physical key — `None` for keys the reference has no name for (they are
/// ignored for binding, the client's own `UNKNOWN` posture). Modifier keys are deliberately
/// absent: they are chord *prefixes*, never base keys (`IsKeyPressIgnoredForBinding`).
///
/// `NumpadEnter` shares `ENTER` with the main Enter key (1.12 has one token; [`normalize_key`]
/// folds the pair for dispatch too).
/// The 1.12 token for a physical key; `None` for a key the reference names `UNKNOWN` and for the
/// modifiers, which are only prefixes (`IsKeyPressIgnoredForBinding`). Both Enters are `ENTER`.
pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
use KeyCode::*;
Some(match k {
@@ -173,36 +147,17 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
F10 => "F10",
F11 => "F11",
F12 => "F12",
// **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"`; 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.
// On a Mac F13 is print-screen: the Mac key table `0x5bf320` maps keycode `0x69` to
// `0x212`, `PRINTSCREEN`, and macOS has no PrintScreen keycode.
#[cfg(target_os = "macos")]
F13 => "PRINTSCREEN",
// **F13-F24: bindable strings, and `F16` a real reference code.**
// `IsValidBindingKeyString` arm 3 takes `F` + digits with no ceiling (`0x846c04`), and the
// Mac table really does reach `F16` — keycode `0x6A` → `0x30d`. Naming only F1-F12 meant
// `SetBinding("F16", …)` stored a chord this codec could never press: the key vanished
// into `unpressable chord` at the next seed.
//
// **Above F12 this is a benilla extension, deliberately.** The reference cannot reach
// them: Windows' fixed table stops at `VK_F12` and everything past it falls to
// `MapVirtualKeyA(vk, MAPVK_VK_TO_CHAR)`, which returns 0 for an F-key and drops the
// message outright. That is a 2004 lookup table's silence, not a rule about keys.
// `IsValidBindingKeyString` accepts `F` + any digits (`0x846c04`) and the Mac table maps
// keycode `0x6A` to `F16` (`0x30d`). Deviation: the Windows key table stops at `VK_F12`
// and drops later F-keys; they are named here so a stored `F13`-`F24` can be pressed.
#[cfg(not(target_os = "macos"))]
F13 => "F13",
// **F14 and F15 are the Mac's ScrollLock and Pause, and those two are unbindable.** The
// Mac table sends keycode `0x6B` (F14) to `0x210` and `0x71` (F15) to `0x211` — the exact
// pair the namer calls `UNKNOWN` — so on a Mac they are refused for the same reason
// ScrollLock and Pause are refused below: they ARE those keys. (1.12's
// `KEY_SCROLLLOCK_MAC` / `KEY_PAUSE_MAC` are dead labels for the same reason; neither
// binary carries a `SCROLLLOCK` or `PAUSE` string at all.)
//
// The line that draws: a key the reference deliberately maps to `UNKNOWN` stays
// unbindable; a key it simply never mapped, we may name.
// On a Mac F14 and F15 are ScrollLock and Pause: keycodes `0x6B` and `0x71` map to
// `0x210` and `0x211`, which the namer calls `UNKNOWN`, so they stay unbindable there.
#[cfg(not(target_os = "macos"))]
F14 => "F14",
#[cfg(not(target_os = "macos"))]
@@ -246,16 +201,12 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
NumpadDivide => "NUMPADDIVIDE",
NumpadMultiply => "NUMPADMULTIPLY",
NumpadDecimal => "NUMPADDECIMAL",
// The Mac numeric keypad's `=` key. It is in the reference's namer (`0x30c`) and in
// `IsValidBindingKeyString`'s 26-name table, and it was in OUR validator too — but not
// here, so it was the one token `SetBinding` accepted and the dispatcher could not press.
// The Mac keypad's `=`: the namer's `0x30c`, and in `IsValidBindingKeyString`'s table.
NumpadEqual => "NUMPADEQUALS",
NumLock => "NUMLOCK",
PrintScreen => "PRINTSCREEN",
// 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.
// No ScrollLock or Pause: the namer (`0x4b66b0`) calls `0x210`/`0x211` `UNKNOWN`, and
// `IsValidBindingKeyString` (`0x4b7890`) has neither name.
CapsLock => "CAPSLOCK",
Minus => "-",
Equal => "=",
@@ -277,20 +228,16 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
pub(crate) fn normalize_key(k: KeyCode) -> KeyCode {
match k {
KeyCode::NumpadEnter => KeyCode::Enter,
// The Mac print-screen key — see [`key_token`]'s arm for the evidence. Folded here as well
// as there because the two arms serve different halves: `key_token` names the key the
// capture arm just swallowed, this one makes a press DISPATCH against a chord parsed from
// the token. Only both together make `PRINTSCREEN` a working binding on a Mac.
// The Mac print-screen key; must agree with `key_token`, which names it for capture.
#[cfg(target_os = "macos")]
KeyCode::F13 => KeyCode::PrintScreen,
other => other,
}
}
/// The 1.12 token for a mouse button. BUTTON4 is winit's `Forward` and BUTTON5 `Back` —
/// deliberately keeping the thumb button that toggled autorun before 0997 (macOS winit maps
/// NSEvent buttonNumber 4 → `Forward`; see the old `player.rs` site) on the 1.12 default
/// (`BUTTON4 TOGGLEAUTORUN`), so the director's in-hand behavior survives the refactor.
/// The 1.12 token for a mouse button: BUTTON4 is winit's `Forward` and BUTTON5 its `Back`, so the
/// 1.12 default `BUTTON4 TOGGLEAUTORUN` sits on `Forward`. Which physical button the reference
/// calls BUTTON4 is untraced.
pub(crate) fn mouse_token(b: MouseButton) -> Option<&'static str> {
Some(match b {
MouseButton::Left => "BUTTON1",
@@ -298,26 +245,19 @@ pub(crate) fn mouse_token(b: MouseButton) -> Option<&'static str> {
MouseButton::Middle => "BUTTON3",
MouseButton::Forward => "BUTTON4",
MouseButton::Back => "BUTTON5",
// **The extra buttons on a real mouse.** The reference names them the same way it names
// the first five and then some: `0x4b6aa0`'s fallback arm bit-scans from bit 3 upward and
// `sprintf("BUTTON%{d}", n)`, and `IsValidBindingKeyString` arm 3 takes `BUTTON` +
// digits with no ceiling — so `BUTTON6` was always a storable, listable, *unpressable*
// chord here. winit hands the platform's own button number through `Other(n)`, and on
// macOS that is `NSEvent.buttonNumber` — 0-4 already spoken for by the five named
// variants, so the sixth physical button arrives as `Other(5)` and is `BUTTON6`.
// The reference names further buttons `BUTTON<n>` (`0x4b6aa0`'s bit-scan fallback).
// winit passes the platform's button number, so the sixth button is `Other(5)`.
MouseButton::Other(n) => return EXTRA_BUTTONS.get(usize::from(n).wrapping_sub(5)).copied(),
})
}
/// `BUTTON6`…`BUTTON20`, indexed by `Other(n) - 5`. A bounded table rather than a formatted
/// string because these names are `&'static str` on both sides of the codec; twenty buttons is
/// past every mouse anyone ships and the reference's own namer stops at the first gap too.
/// `BUTTON6` to `BUTTON20`, indexed by `Other(n) - 5`.
const EXTRA_BUTTONS: &[&str] = &[
"BUTTON6", "BUTTON7", "BUTTON8", "BUTTON9", "BUTTON10", "BUTTON11", "BUTTON12", "BUTTON13",
"BUTTON14", "BUTTON15", "BUTTON16", "BUTTON17", "BUTTON18", "BUTTON19", "BUTTON20",
];
/// Token → base input (the parse side of [`key_token`]/[`mouse_token`], plus the wheel pair).
/// Token to base input: the inverse of [`key_token`] and [`mouse_token`], plus the wheel pair.
fn token_key(t: &str) -> Option<BindKey> {
use KeyCode::*;
if let Some(b) = match t {
@@ -447,18 +387,8 @@ fn token_key(t: &str) -> Option<BindKey> {
"`" => Backquote,
_ => return None,
};
// **Through the alias fold, and only if the namer agrees.** Two invariants, both of which
// this codec broke somewhere before 1745:
//
// 1. A chord parsed from a token must compare equal to the one a press BUILDS, and a press is
// normalized ([`normalize_key`]) — so `ENTER` has to land on the same `KeyCode` the
// numpad's Enter folds to.
// 2. A token this side accepts must be one [`key_token`] would produce, on THIS platform.
// Otherwise the two tables drift: on a Mac `F13` is the print-screen key and `F14`/`F15`
// are the unbindable ScrollLock/Pause pair, so parsing those strings to their raw
// `KeyCode`s would make a chord the capture arm can never write and the window can never
// show, reachable only by hand-editing the file. Asking the namer closes it structurally
// rather than by remembering to cfg both sides.
// Fold aliases so a parsed chord equals a normalized press, and accept only a token the
// namer produces on this platform (on a Mac, F13-F15 are other keys).
let k = normalize_key(k);
if key_token(k) != Some(t) {
return None;
@@ -470,15 +400,8 @@ fn token_key(t: &str) -> Option<BindKey> {
mod tests {
use super::*;
/// **Every token this codec names is one `SetBinding` will actually take** (decision 1295).
/// The namer here and `IsValidBindingKeyString` in the engine table are two transcriptions of
/// the same reference, and nothing but this ties them together — a token we name but the
/// validator refuses is a key the capture seam happily offers and `SetBinding` then drops on
/// the floor, which is exactly what `SCROLLLOCK` and `PAUSE` were until 1295.
///
/// The registry's default chords are covered exhaustively; `KeyCode` cannot be enumerated, so
/// the namer is covered by one key of every SHAPE it produces (letter, digit, F-key, numpad
/// digit, numpad name, arrow, the named editing/lock keys, punctuation).
/// Ties this namer to the engine's `IsValidBindingKeyString`: every default chord, and one key
/// of each token shape, since `KeyCode` cannot be enumerated.
#[test]
fn every_token_the_codec_names_is_one_setbinding_accepts() {
use benilla_ui::script::keybind::normalize_binding_key;
@@ -545,35 +468,20 @@ mod tests {
for token in ["MOUSEWHEELUP", "MOUSEWHEELDOWN"] {
assert!(normalize_binding_key(token).is_some());
}
// The other half of the same law: the reference's namer calls these two `UNKNOWN`
// (`0x210`/`0x211`), so we must not name them either — nothing downstream could bind them.
// The reference's namer calls these `UNKNOWN` (`0x210`/`0x211`).
assert_eq!(key_token(KeyCode::ScrollLock), None);
assert_eq!(key_token(KeyCode::Pause), None);
}
/// **And the way back — the direction that actually bit.** The test above walks the codec's
/// output into the validator; nothing walked the validator's *input space* back into the
/// codec, and three families lived in the gap: `NUMPADEQUALS` (in the reference's namer at
/// `0x30c`, in the 26-name table, in our validator — and nowhere here), `F13`-`F24`
/// (`IsValidBindingKeyString` arm 3 is `F` + digits with no ceiling), and `BUTTON6`+ (arm 3
/// again, and `0x4b6aa0`'s bit-scan fallback). Each was a key `SetBinding` accepted, the
/// window listed, the file saved — and the dispatcher could never press, so it came back as
/// `bindings: <cmd>: unpressable chord` and the player's key did nothing.
///
/// The accept set is infinite (any single character, `F`/`NUMPAD`/`BUTTON` + any digits), so
/// this pins the families that name a key a real keyboard or mouse HAS. Past those — `F25`,
/// `BUTTON21`, `NUMPAD11` — the validator still accepts and the codec still refuses, which is
/// correct and deliberate: there is no physical key to press, and inventing a `KeyCode` for
/// one would be inventing hardware.
/// The validator's accept set is infinite, so this covers the tokens a real keyboard or mouse
/// has; `F25`, `BUTTON21` and `NUMPAD11` stay unpressable, as no such key exists.
#[test]
fn every_token_setbinding_accepts_for_a_real_key_is_one_the_codec_can_press() {
use benilla_ui::script::keybind::normalize_binding_key;
let mut tokens: Vec<String> = Vec::new();
tokens.extend((1..=24).map(|n| format!("F{n}")));
// On a Mac those three are other keys: F13 IS print-screen (`0x5bf320`: keycode `0x69` →
// `0x212`) and F14/F15 are the unbindable ScrollLock/Pause pair (`0x210`/`0x211`). The
// codec refuses them there on purpose, so the list has to as well.
// On a Mac F13 is print-screen and F14/F15 are the unbindable ScrollLock and Pause.
if cfg!(target_os = "macos") {
tokens.retain(|t| !matches!(t.as_str(), "F13" | "F14" | "F15"));
}
@@ -631,7 +539,6 @@ mod tests {
#[test]
fn the_codec_round_trips_every_keyboard_token() {
// Every key with a token parses back to itself (through the alias fold).
let mut checked = 0;
for k in [
KeyCode::KeyW,
@@ -654,14 +561,11 @@ mod tests {
checked += 1;
}
assert_eq!(checked, 10);
// The alias fold: both Enters share the token and the parsed key.
assert_eq!(key_token(KeyCode::NumpadEnter), Some("ENTER"));
assert_eq!(token_key("ENTER"), Some(BindKey::Key(KeyCode::Enter)));
}
/// The Mac print-screen fold (decision 1487). macOS delivers no `PrintScreen` at all — a PC
/// keyboard's PrtSc arrives as F13 — so BOTH arms have to agree that F13 *is* `PRINTSCREEN`,
/// or the shipped `PRINTSCREEN SCREENSHOT` default is a key nobody on a Mac can press.
/// macOS has no `PrintScreen`, so capture and dispatch must both read F13 as `PRINTSCREEN`.
#[cfg(target_os = "macos")]
#[test]
fn f13_is_print_screen_on_a_mac() {
@@ -693,7 +597,7 @@ mod tests {
key: BindKey::Key(KeyCode::F1)
})
);
// `CTRL--` is Ctrl + the minus key — the prefix strip never splits the base token.
// `CTRL--` is Ctrl + the minus key.
assert_eq!(
Chord::parse("CTRL--"),
Some(Chord {
@@ -722,7 +626,6 @@ mod tests {
})
);
assert_eq!(Chord::parse("BOGUS"), None);
// The builder emits the same canon the parser reads.
assert_eq!(
chord_string(true, false, true, "PAGEDOWN"),
"ALT-SHIFT-PAGEDOWN"
+114 -330
View File
@@ -1,35 +1,16 @@
//! The command registry (decision 0997) — every binding command benilla actually implements, in
//! 1.12 `Bindings.xml` order, with the 1.12 default chords (byte-real: the client's own
//! `bindings-cache.wtf`, account ONE) and each command's dispatch class.
//! The binding command registry: every command benilla implements, in 1.12 `Bindings.xml` order,
//! with the stock default chords (`WTF\DefaultBindings.wtf`) and each command's dispatch class.
//! A command appears only over a real engine action; the rest are in [`ABSENT`], and the two
//! together are exactly the client's 228 live bindings. Labels and headers are the 1.12
//! `BINDING_NAME_*`/`BINDING_HEADER_*` GlobalStrings.
//!
//! **Honest tree**: a command appears here only over a real engine action — the same law as the
//! options rows (0954). The 1.12 commands with no benilla mechanism yet are absent, not stubbed;
//! the page shows only what's here, and only non-empty categories (era law). Labels/headers are
//! the 1.12 GlobalStrings (`BINDING_NAME_*`/`BINDING_HEADER_*`), defined in the window's XML.
//!
//! **…and the absence is written down** ([`ABSENT`], decision 1745). The honest tree's one hole
//! was that nothing noticed when a mechanism ARRIVED: 0997 promised "each returns the day its
//! mechanism lands, one registry row", and then the keyring (0765), the pet book (1050), the
//! reputation and honor pages, the six action-bar pages and the two vertical multibars (1500)
//! all shipped their mechanism with no row — some for five hundred commits, none of them a
//! mistake anyone could see. `SPECS` ∪ `ABSENT` is now exactly the client's 228 live bindings,
//! and each absent row names the Lua globals whose arrival falsifies it.
//!
//! Three dispatch classes:
//! - [`Kind::Held`] — press latches, base-key release unlatches (the reference's `runOnUp`
//! movement pairs); engine systems read the latch ([`super::BindingsState`]).
//! - [`Kind::Edge`] / [`Kind::EdgeUpDown`] — fires Lua in the VM (the reference's binding body,
//! quoted 1:1 where our FrameXML port has the same functions).
//! - [`Kind::Host`] — fires into [`super::BindingsState::fired`]; an engine system consumes it
//! (chat open, TAB targeting, nameplates, autorun, …).
//!
//! Recorded default divergences (0997): `TOGGLEUI` ships `ALT-Z` (0870: the cache's `CTRL-Z` is
//! a player rebind all three accounts inherited, not the shipped default — the one row that does
//! not trust the cache). That is now the ONLY one — the bag row's divergence (`OPENALLBAGS` wearing
//! both `B` and `SHIFT-B`, because benilla had a single all-bags knob) is gone as of 1494: the
//! reference's split ships whole, keys and bodies alike.
//! Dispatch classes:
//! - [`Kind::Held`]: press latches, base-key release unlatches (the `runOnUp` movement pairs);
//! engine systems read the latch ([`super::BindingsState`]).
//! - [`Kind::Edge`] / [`Kind::EdgeUpDown`]: runs the reference's binding body as Lua.
//! - [`Kind::Host`]: fires into [`super::BindingsState::fired`] for an engine system.
/// A command's index into [`SPECS`] — the engine-side handle (`cmd::JUMP`).
/// A command's index into [`SPECS`], the engine-side handle (`cmd::JUMP`).
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) struct Cmd(pub u16);
@@ -46,19 +27,17 @@ pub(crate) enum Kind {
pub(crate) struct Spec {
pub name: &'static str,
/// The category header's global-string key (`BINDING_HEADER_MOVEMENT`) — the window's
/// sidebar groups by it, era-style.
/// The category header's global-string key (`BINDING_HEADER_MOVEMENT`).
pub category: &'static str,
pub kind: Kind,
/// The 1.12 default chords (canonical strings; `None` = shipped unbound).
/// The 1.12 default chords; `None` is shipped unbound.
pub d1: Option<&'static str>,
pub d2: Option<&'static str>,
}
impl Spec {
/// Press+release semantics — the reference's `<Binding runOnUp=…>`, whose ONE reader is
/// `RunCommand 0x4b7bf1`: it gates the release half and nothing else. (It used to gate a
/// mousewheel refusal at `SetBinding` time here; that refusal is not in the client — 1295.)
/// The reference's `<Binding runOnUp=…>`; its one reader (`0x4b7bf1`) gates the release half
/// and nothing else.
pub(crate) fn run_on_up(&self) -> bool {
matches!(self.kind, Kind::Held | Kind::EdgeUpDown(..))
}
@@ -76,21 +55,11 @@ macro_rules! spec {
};
}
/// Engine-side handles for the host-dispatched commands (indexes into [`SPECS`], asserted by
/// test). Lua-bodied commands need no handle — nothing in Rust names them.
/// Engine-side handles for the host-dispatched commands, as indexes into [`SPECS`].
pub(crate) mod cmd {
use super::{Cmd, TABLE};
/// Resolve a command NAME to its [`Cmd`] handle at compile time — a linear scan of [`TABLE`]
/// in a `const fn`, so a name that is not in the registry is a BUILD error, not a runtime
/// surprise.
///
/// These handles used to be hand-written row numbers, and every row that landed ahead of one
/// silently re-pointed it at its neighbour: 1057's `TOGGLECHARACTER3` moved three of them
/// 86/87/88 → 87/88/89, 1136's `TOGGLEACTIONBARLOCK` moved the same three again, and 1494's
/// bag family would have moved them a third time (`TOGGLE_UI` landing on `MINIMAPZOOMOUT`). A
/// guard test named the drift but the numbers still had to be re-typed by hand each time; the
/// name is the thing we actually mean, so the name is what the table is asked for now.
/// Resolve a command name to its [`Cmd`] at compile time; an unknown name is a build error.
const fn by_name(name: &str) -> Cmd {
let mut i = 0;
while i < TABLE.len() {
@@ -140,11 +109,8 @@ pub(crate) mod cmd {
pub(crate) const CAMERA_ZOOM_OUT: Cmd = by_name("CAMERAZOOMOUT");
}
/// The registry, 1.12 `Bindings.xml` order. Sub-tables (action buttons, shapeshift, raid
/// targets) are written out because each row carries its own Lua body string.
///
/// `TABLE` is the `const` view [`cmd::by_name`] scans at compile time (a `static` cannot be read
/// during const evaluation); `SPECS` is the one everything else uses.
/// The registry, in 1.12 `Bindings.xml` order. `TABLE` is the `const` view [`cmd::by_name`] scans,
/// since a `static` cannot be read during const evaluation.
pub(crate) static SPECS: &[Spec] = TABLE;
const TABLE: &[Spec] = &[
@@ -229,9 +195,7 @@ const TABLE: &[Spec] = &[
Some("R"),
None
),
// The other reply: the last person YOU told, not the last who told you
// (`ChatEdit_GetLastToldTarget`, ChatFrame.lua l.1650). The memory was already being kept by
// the send path and read by nothing — 1745.
// The last person you told, not the last who told you (`ChatFrame.lua:1650`).
spec!(
"REPLY2",
CHAT,
@@ -239,9 +203,7 @@ const TABLE: &[Spec] = &[
Some("SHIFT-R"),
None
),
// The combat-log four (Bindings.xml l.108-119): ChatFrame2's own paging and the reference's
// `ToggleCombatLog`, both FloatingChatFrame.lua's since the chat window became the
// reference's (1948). Chords from `bindings-cache.wtf`, account ONE.
// The combat-log four (`Bindings.xml:108-119`): ChatFrame2's paging and `ToggleCombatLog`.
spec!(
"COMBATLOGPAGEUP",
CHAT,
@@ -271,8 +233,7 @@ const TABLE: &[Spec] = &[
None
),
// ── Action bar (BINDING_HEADER_ACTIONBAR) ───────────────────────────────────────────
// The ref's runOnUp pair (Bindings.xml:121: DOWN shows the pushed visual, UP fires) —
// exactly what the old hardcoded number-row table sent.
// The `runOnUp` pair (`Bindings.xml:121`): down shows the pushed visual, up fires.
spec!(
"ACTIONBUTTON1",
ACTIONBAR,
@@ -357,13 +318,9 @@ const TABLE: &[Spec] = &[
Some("="),
None
),
// The stance/shapeshift row (ref ShapeshiftBar_ChangeForm(n)) — ours clicks the bar's own
// buttons, which carry the full form-switch law (stock BonusActionBarFrame.xml, 1938).
// ── The self-cast dozen (1.12 `Bindings.xml`:257-293) ───────────────────────────────
// The same two halves as ACTIONBUTTON, with `ActionButtonUp`'s second argument set: the
// reference's own `onSelf`, which stock `ActionButtonUp` forwards to `UseAction`'s third
// and the host used to drop (1745). `ALT-1`…`ALT-=` are byte-real from DefaultBindings.wtf,
// and they sit one modifier off the plain bar exactly as the reference lays them out.
// ── Self-cast (`Bindings.xml:205-288`) ─────────────────────────────────────────────
// ACTIONBUTTON's halves with `ActionButtonUp`'s `onSelf` argument set, which stock
// `ActionButtonUp` forwards to `UseAction`.
spec!(
"SELFACTIONBUTTON1",
ACTIONBAR,
@@ -448,6 +405,8 @@ const TABLE: &[Spec] = &[
Some("ALT-="),
None
),
// The reference body is `ShapeshiftBar_ChangeForm(n)`; this clicks the visible stock
// button, whose OnClick calls it (`BonusActionBarFrame.xml:37`).
spec!(
"SHAPESHIFTBUTTON1",
ACTIONBAR,
@@ -538,14 +497,9 @@ const TABLE: &[Spec] = &[
Some("CTRL-F10"),
None
),
// The PET bar's row — 1.12 calls it BONUSACTIONBUTTON and files it under this same ACTIONBAR
// header (Bindings.xml:321-390 carries no `header` attribute, so it inherits l.121's), which
// is why "Secondary Action Button 1-10" reads under Action Bar in the reference's own window.
// The ref's body is BonusActionButtonDown/Up(id), and those two are one-liners onto
// PetActionButtonDown/Up (BonusActionBarFrame.lua:106-112) — the PET bar's buttons, not the
// bonus bar's. benilla has no bonus bar at all, so the pet bar is the whole lane, exactly as
// the reference wires it. Defaults are byte-real and unanimous: CTRL-1..CTRL-0 in all three
// of the install's `bindings-cache.wtf` files (unlike TOGGLEUI's rebind, 0870).
// The pet bar, which 1.12 names BONUSACTIONBUTTON under the ACTIONBAR header
// (`Bindings.xml:321-390`); its `BonusActionButtonDown/Up` only call
// `PetActionButtonDown/Up` (`BonusActionBarFrame.lua:106-112`).
spec!(
"BONUSACTIONBUTTON1",
ACTIONBAR,
@@ -616,12 +570,9 @@ const TABLE: &[Spec] = &[
Some("CTRL-0"),
None
),
// ── The action-bar PAGES (1.12 `Bindings.xml`:395-431) ──────────────────────────────
// The bar is six pages of twelve (action slots 1..72) and it has been since 1500 shipped the
// multibars; `ChangeActionBarPage` is the engine's (it fires ACTIONBAR_PAGE_CHANGED and nothing
// else — 1938) and the `ActionBar_Page{Up,Down}` wrap is stock ActionButton.lua's. `SHIFT-1..6`
// and the SHIFT-arrow / SHIFT-wheel steps are
// byte-real from `WTF\\DefaultBindings.wtf`.
// ── Action-bar pages (`Bindings.xml:391-432`) ──────────────────────────────────────
// Six pages of twelve (slots 1-72). `ChangeActionBarPage` is an engine verb that only fires
// `ACTIONBAR_PAGE_CHANGED`; the `ActionBar_Page{Up,Down}` wrap is stock ActionButton.lua's.
spec!(
"ACTIONPAGE1",
ACTIONBAR,
@@ -696,13 +647,8 @@ const TABLE: &[Spec] = &[
Some("SHIFT-DOWN"),
Some("SHIFT-MOUSEWHEELDOWN")
),
// The action-bar lock (decision 1136), the ref's own binding body verbatim (Bindings.xml:433-
// 439) — it flips the `LOCK_ACTIONBAR` uvar OptionsFrame.xml declares (1938), the same global the
// Options window's Action Bars row writes. It sits here because the reference files it under
// this header (l.433 carries no `header=`, so it inherits l.121's ACTIONBAR), and it ships
// **unbound**: no `TOGGLEACTIONBARLOCK` line in any of the install's three
// `bindings-cache.wtf` files, which is also what the option's own tooltip implies ("can be
// bound to a function key in the keybindings interface").
// Flips the `LOCK_ACTIONBAR` uvar the Options window's Lock ActionBars row writes
// (`Bindings.xml:433-439`).
spec!(
"TOGGLEACTIONBARLOCK",
ACTIONBAR,
@@ -712,9 +658,6 @@ const TABLE: &[Spec] = &[
None,
None
),
// The `autoSelfCast` CVar's own toggle, the reference's body verbatim — real since 1745
// wired that CVar to the cast arm's `AutoSelfCast` knob, which had been welded to a Resource
// default with nothing able to move it. Ships unbound, like the reference.
spec!(
"TOGGLEAUTOSELFCAST",
ACTIONBAR,
@@ -740,9 +683,7 @@ const TABLE: &[Spec] = &[
Some("SHIFT-TAB"),
None
),
// The friendly cone — the same scan with the reaction test flipped (1745). `CTRL-TAB` /
// `CTRL-SHIFT-TAB`, byte-real; the reverse flag is the reference's own
// `TargetNearestFriend(1)`, and its `Bindings.xml` comment says so out loud.
// The friendly scan; `1` is the reverse flag (`Bindings.xml:458`).
spec!(
"TARGETNEARESTFRIEND",
TARGETING,
@@ -757,8 +698,7 @@ const TABLE: &[Spec] = &[
Some("CTRL-SHIFT-TAB"),
None
),
// The self/party bodies are 1.12's own, 1:1 (Bindings.xml:460-509 — already-targeted
// falls through to the pet).
// An already-targeted unit falls through to its pet (`Bindings.xml:460-494`).
spec!(
"TARGETSELF",
TARGETING,
@@ -878,21 +818,8 @@ const TABLE: &[Spec] = &[
Some("C"),
None
),
// The bag family, in the reference's own `Bindings.xml` order and with its own bodies and
// defaults (1494). 0997 collapsed all of this onto ONE command — `OPENALLBAGS` wearing both
// `B` and `SHIFT-B` over a `ToggleBackpack()` that opened every bag — because benilla had a
// single all-bags knob to hang keys off. The reference has three knobs and the director
// reported the difference: `B` opens the backpack ALONE, `SHIFT-B` opens the lot.
//
// Every default here is byte-real from the client's own `bindings-cache.wtf`, identical across
// all three independent accounts (ONE, TWO, WINUSER) — which is what rules out a player rebind
// (the `TOGGLEUI` trap, 0870).
//
// Each body is the bare GLOBAL, exactly the ref's own `Bindings.xml` — NOT a button's OnClick
// handler: a handler carries the button's checked bookkeeping, and routing a key through it
// drags that onto a path the reference keeps clean (with a bag addon holding `ToggleBackpack`,
// the key press is the path that lights the backpack button on the real client — the addon's
// OnShow write is the last word).
// The bag family: `B` opens the backpack alone, `SHIFT-B` every bag. Each body is the bare
// global, as in `Bindings.xml`, never a button's OnClick, which would add its checked state.
spec!(
"TOGGLEBACKPACK",
INTERFACE,
@@ -900,8 +827,8 @@ const TABLE: &[Spec] = &[
Some("B"),
Some("F12")
),
// TOGGLEBAG**N** is bag **5-N**: the reference's numbering runs the bar right-to-left, so F8
// opens the slot FARTHEST from the backpack. Quoted from ref Bindings.xml l.564-575.
// TOGGLEBAGn is bag 5-n, so F8 opens the slot farthest from the backpack
// (`Bindings.xml:564-575`).
spec!(
"TOGGLEBAG1",
INTERFACE,
@@ -937,9 +864,6 @@ const TABLE: &[Spec] = &[
Some("SHIFT-B"),
None
),
// The keyring's own toggle (1.12 `Bindings.xml`; ships UNBOUND, and the reference's page
// shows it as such). 0765 landed the keyring plate and its `HasKey` gate; the row it was
// owed has been missing since.
spec!(
"TOGGLEKEYRING",
INTERFACE,
@@ -954,8 +878,7 @@ const TABLE: &[Spec] = &[
Some("P"),
None
),
// The PET book is the same window forked on `bookType` — 1050's law, and the reason this is
// a second command rather than a second key on the first.
// The pet book is the same window forked on `bookType`.
spec!(
"TOGGLEPETBOOK",
INTERFACE,
@@ -970,9 +893,8 @@ const TABLE: &[Spec] = &[
Some("N"),
None
),
// TOGGLECHARACTER**N** is the reference's PAGE number, not our tab index: 0 = PaperDoll,
// 1 = Skill, 2 = Reputation, 3 = PetPaperDoll, 4 = Honor. The rows run 4, 3, 2, 1 in the
// file, and the file's order is this table's order.
// TOGGLECHARACTERn is the reference's page number, not a tab index: 0 PaperDoll, 1 Skill,
// 2 Reputation, 3 PetPaperDoll, 4 Honor; the file runs them 4, 3, 2, 1.
spec!(
"TOGGLECHARACTER4",
INTERFACE,
@@ -980,9 +902,6 @@ const TABLE: &[Spec] = &[
Some("H"),
None
),
// `SHIFT-P` is byte-real from the client's own `bindings-cache.wtf`, and identical in two
// independent accounts (ONE and WINUSER) — which is what rules out a player rebind (the
// `TOGGLEUI` trap, 0870). Decision 1057.
spec!(
"TOGGLECHARACTER3",
INTERFACE,
@@ -1069,10 +988,8 @@ const TABLE: &[Spec] = &[
None,
None
),
// The battleground scoreboard and the battlefield minimap — the Interface block's last two
// (Bindings.xml l.630-635), both Lua-bodied: `ToggleWorldStateScoreFrame` is the stock
// WorldStateFrame.lua's since 1972 and `ToggleBattlefieldMinimap` UIParent.xml's (ref
// UIParent.lua l.216-221). Defaults byte-real from `WTF\DefaultBindings.wtf`.
// `ToggleWorldStateScoreFrame` is stock WorldStateFrame.lua's, `ToggleBattlefieldMinimap`
// stock `UIParent.lua:216` (`Bindings.xml:630-635`).
spec!(
"TOGGLEWORLDSTATESCORES",
INTERFACE,
@@ -1102,8 +1019,8 @@ const TABLE: &[Spec] = &[
Some("NUMPADMINUS"),
None
),
// The sound toggles/steps flip the same CVars the ref's SoundOptionsFrame_* bodies do
// (1.12 SoundOptionsFrame.lua: master enable = MasterSoundEffects, step 0.1).
// The CVars stock SoundOptionsFrame.lua moves: `MasterSoundEffects` is the master enable,
// `MasterVolume` steps by 0.1.
spec!(
"TOGGLEMUSIC",
MISC,
@@ -1140,25 +1057,9 @@ const TABLE: &[Spec] = &[
Some("CTRL--"),
None
),
// ALT-Z, not the cache's CTRL-Z — settled by 0870: all three of the install's accounts
// descend from one profile whose TOGGLEUI had been rebound; ALT-Z is the shipped default
// (the one command whose default does NOT trust the cache file).
// ALT-Z is the shipped default; a `bindings-cache.wtf` holding CTRL-Z is a player rebind.
spec!("TOGGLEUI", MISC, Kind::Host, Some("ALT-Z"), None),
// Print screen (decision 1487). The body is the reference's own one-liner because our
// `TakeScreenshot` has the same contract its does (ScreenshotStatus.xml) — hide the last
// 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` (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.
// The stock WorldFrame.lua's framerate readout (decision 1983): `FramerateLabel`/`FramerateText`
// toggled by `ToggleFramerate()`, refreshed off `GetFramerate()` every quarter second by
// `WorldFrame_OnUpdate`. CTRL-R is the install's own DefaultBindings.wtf chord (1804).
// Stock WorldFrame.lua's framerate readout.
spec!(
"TOGGLEFPS",
MISC,
@@ -1166,6 +1067,8 @@ const TABLE: &[Spec] = &[
Some("CTRL-R"),
None
),
// `Edge`: the `<Binding>` has no `runOnUp`, and the dispatcher returns on key-up without it
// (`0x4b7bea`).
spec!(
"SCREENSHOT",
MISC,
@@ -1174,11 +1077,9 @@ const TABLE: &[Spec] = &[
None
),
// ── Camera (BINDING_HEADER_CAMERA) ──────────────────────────────────────────────────
// The five named camera views (decision 1745) — `player::camera_view`, whose defaults are the
// reference's own `0x84f488` table. NEXTVIEW/PREVVIEW ship on END/HOME and **do not wrap**
// (`0x50faa0`/`0x50fac0` are hard stops); Set/Save/Reset ship unbound, and 1.12 files no
// SAVEVIEW1/RESETVIEW1 row even though its engine accepts view 1 — that is a `Bindings.xml`
// decision, so the table follows the file.
// The five camera views (defaults: the table at `0x84f488`). NEXTVIEW/PREVVIEW stop at the
// ends (`0x50faa0`/`0x50fac0`); 1.12 has no SAVEVIEW1/RESETVIEW1 row though the engine
// accepts view 1.
spec!(
"NEXTVIEW",
CAMERA,
@@ -1228,12 +1129,9 @@ const TABLE: &[Spec] = &[
None
),
// ── MultiActionBar (BINDING_HEADER_MULTIACTIONBAR) ──────────────────────────────────
// The two bottom bars' buttons (stock MultiActionBars.xml renders exactly these; 1.12's right bars
// and their MULTIACTIONBAR3/4 commands stay out — honest tree). Ref bodies are the
// MultiActionButtonDown/Up runOnUp pair (Bindings.xml:799-966), defined in stock
// MultiActionBars.lua; shipped UNBOUND like the ref (no MULTIACTIONBAR* line in any of the
// install's bindings-cache.wtf files). 1.12 files bar 2 under a BLANK spacer-header;
// both bars sit under the one MULTIACTIONBAR header here (1008, recorded).
// The four multibars' `runOnUp` pairs (`Bindings.xml:799-1134`), stock MultiActionBars.lua's.
// Deviation: 1.12 files bars 2-4 under the spacer headers `BLANK`, `BLANK2` and `BLANK3`; they
// sit under MULTIACTIONBAR here because this page's sections are named.
spec!(
"MULTIACTIONBAR1BUTTON1",
MULTIACTIONBAR,
@@ -1474,11 +1372,7 @@ const TABLE: &[Spec] = &[
None,
None
),
// ── The two VERTICAL bars (1.12 files them under its own `BLANK2`/`BLANK3` spacer
// headers; they join bar 2 under MULTIACTIONBAR here for the same reason 1008 folded that
// one — a spacer is a display device in the reference's flat list, and this page's sections
// are named). Both bars are real since 1500; `MultiBarRight` is bar 3, `MultiBarLeft` bar 4,
// and both ship UNBOUND exactly as the reference does.
// The vertical bars: `MultiBarRight` is bar 3, `MultiBarLeft` bar 4.
spec!(
"MULTIACTIONBAR3BUTTON1",
MULTIACTIONBAR,
@@ -1720,8 +1614,7 @@ const TABLE: &[Spec] = &[
None
),
// ── Raid targeting (BINDING_HEADER_RAID_TARGET) ─────────────────────────────────────
// 1.12 bodies 1:1 (SetRaidTargetIcon toggles when the unit already wears the icon; 0
// clears — party.rs's registered semantics). All unbound by default, like the client.
// `SetRaidTargetIcon` toggles an icon the unit already wears off; 0 clears.
spec!(
"RAIDTARGET1",
RAID_TARGET,
@@ -1787,34 +1680,15 @@ const TABLE: &[Spec] = &[
),
];
/// One 1.12 binding command this client does **not** register — and the mechanism it waits on.
///
/// The honest tree (0997) says a command appears in [`SPECS`] only over a real engine action. That
/// rule is right and it stays; what it never had was a way to notice when the action *arrived*.
/// 0997's own residue promised "each returns the day its mechanism lands, one registry row" and
/// then five mechanisms landed without their rows: the keyring (0765), the pet book (1050), the
/// reputation and honor pages (1057-era), the six action-bar pages and the two vertical multibars
/// (1500). Nothing was wrong with any of those commits — nothing was *watching*.
///
/// So the absence is written down rather than merely true, and it is written down in a form that
/// goes stale loudly: [`needs`] names the Lua globals the reference's own binding body calls that
/// this client does not define, and
/// `the_absent_commands_are_still_absent` fails the day the last one lands.
/// One 1.12 binding command this client does not register, and the mechanism it waits on; a
/// test fails once every global in [`needs`] is defined.
///
/// [`needs`]: Absent::needs
pub(crate) struct Absent {
/// The 1.12 command name, exactly as `Bindings.xml` spells it.
pub name: &'static str,
/// Lua globals the reference's body calls that this client does not define. **This is the
/// falsifier**: while at least one is missing the absence is real; when they are all defined
/// the mechanism has landed and the row belongs in [`SPECS`].
///
/// Empty is allowed and means *there is no Lua-global signal* — the missing mechanism is
/// host-side behind a global that already exists (self-cast lives behind `UseAction`'s third
/// argument, which the Lua passes and the host drops). Those rows carry the whole weight in
/// [`why`], and they are the ones to be suspicious of.
///
/// [`why`]: Absent::why
/// Lua globals the reference's body calls that this client does not define; never empty, and
/// once all are defined the row belongs in [`SPECS`].
#[cfg_attr(
not(test),
expect(
@@ -1825,7 +1699,7 @@ pub(crate) struct Absent {
)
)]
pub needs: &'static [&'static str],
/// What would have to exist here — one line, mechanism-first.
/// What would have to exist, in one line.
pub why: &'static str,
}
@@ -1839,9 +1713,8 @@ macro_rules! absent {
};
}
/// The 1.12 commands benilla does not implement, in `Bindings.xml` order — the other half of the
/// registry, and the half that used to be invisible. `SPECS` ∪ `ABSENT` is exactly the client's
/// 228 live bindings, asserted by [`tests::every_1_12_command_is_registered_or_recorded_absent`].
/// The 1.12 commands benilla does not implement, in `Bindings.xml` order; with `SPECS`, exactly
/// the client's 228 live bindings.
pub(crate) static ABSENT: &[Absent] = &[
// ── Movement ────────────────────────────────────────────────────────────────────────
absent!(
@@ -1855,65 +1728,57 @@ pub(crate) static ABSENT: &[Absent] = &[
["PitchDownStart", "PitchDownStop"],
"no keyboard pitch — see PITCHUP"
),
// ── Action bar ──────────────────────────────────────────────────────────────────────
// ── Interface ───────────────────────────────────────────────────────────────────────
// ── Misc ────────────────────────────────────────────────────────────────────────────
// The nine `hidden="true" debug="true"` rows. Every one of them names an instrument benilla
// really has (the tri counter, the collision display, the portal draw, the perf pill) — they
// are absent because those instruments answer to the dev plane's chords (0702/1043), not to a
// binding. Whether that is right is a real question and 1745 takes it; until then the rows
// stay recorded rather than quietly missing.
// The nine `hidden="true" debug="true"` rows: benilla's instruments for these answer to the
// dev plane's chords, not to a binding.
absent!(
"TOGGLESTATS",
["ToggleStats"],
"a dev-plane instrument (1043)"
"a dev-plane instrument on a dev chord"
),
absent!(
"TOGGLETRIS",
["ToggleTris"],
"a dev-plane instrument (1043)"
"a dev-plane instrument on a dev chord"
),
absent!(
"TOGGLEPORTALS",
["TogglePortals"],
"a dev-plane instrument (1043)"
"a dev-plane instrument on a dev chord"
),
absent!(
"TOGGLECOLLISION",
["ToggleCollision"],
"a dev-plane instrument (1043)"
"a dev-plane instrument on a dev chord"
),
absent!(
"TOGGLECOLLISIONDISPLAY",
["ToggleCollisionDisplay"],
"a dev-plane instrument (1043)"
"a dev-plane instrument on a dev chord"
),
absent!(
"TOGGLEPLAYERBOUNDS",
["TogglePlayerBounds"],
"a dev-plane instrument (1043)"
"a dev-plane instrument on a dev chord"
),
absent!(
"TOGGLEPERFORMANCEDISPLAY",
["TogglePerformanceDisplay"],
"a dev-plane instrument (1043)"
"a dev-plane instrument on a dev chord"
),
absent!(
"TOGGLEPERFORMANCEVALUES",
["TogglePerformanceValues"],
"a dev-plane instrument (1043)"
"a dev-plane instrument on a dev chord"
),
absent!(
"RESETPERFORMANCEVALUES",
["ResetPerformanceValues"],
"a dev-plane instrument (1043)"
"a dev-plane instrument on a dev chord"
),
// ── The mouse's own three (hidden) ──────────────────────────────────────────────────
// These are the reference's mouse-look bindings — BUTTON2 turn-or-action, BUTTON1
// select-or-move, CTRL-BUTTON1 the sticky variant — and they are `hidden` because the window
// never lists them, not because they are debug. benilla's mouse-look is hardwired in
// `player/camera.rs` instead, which is exactly the shape 1043 spent a decision undoing for
// the keyboard.
// The reference's mouse-look bindings (BUTTON2, BUTTON1, CTRL-BUTTON1), hidden from the
// window; benilla's mouse-look is hardwired in `player/camera.rs`.
absent!(
"TURNORACTION",
["TurnOrActionStart", "TurnOrActionStop"],
@@ -1930,9 +1795,7 @@ pub(crate) static ABSENT: &[Absent] = &[
"left-button select/move is hardwired, not routed through a binding"
),
// ── iTunes remote (platform="mac") ──────────────────────────────────────────────────
// The one place 1.12's own binding file is OS-specific: five rows carrying `platform="mac"`,
// the whole use of that attribute in the client. They remote the *system* music player, not
// the game's, and benilla has nothing to remote.
// 1.12's only `platform="mac"` rows; they remote the system music player, not the game's.
absent!(
"ITUNES_PLAYPAUSE",
["MusicPlayer_PlayPause"],
@@ -1964,8 +1827,6 @@ pub(crate) static ABSENT: &[Absent] = &[
mod tests {
use super::*;
/// Every default chord in the table parses — a typo'd token would otherwise silently ship
/// an unpressable default.
#[test]
fn every_default_chord_parses() {
for s in SPECS {
@@ -1979,11 +1840,7 @@ mod tests {
}
}
/// No two commands ship the SAME default chord. One key, one command is the binding table's
/// own law (the steal pass in [`super::store::resolve`] enforces it at load), so a duplicate
/// here would ship a table that eats itself: registration order would decide the winner and
/// the loser would come up silently unbound. Cheap tripwire for the one mistake a new block
/// of rows can make — 1052's CTRL-1..CTRL-0 went in with nothing to catch a collision.
/// One key, one command: a duplicate default would leave the loser of the steal pass unbound.
#[test]
fn no_two_commands_ship_the_same_default_chord() {
let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
@@ -1996,7 +1853,6 @@ mod tests {
}
}
/// Names are unique (the table is keyed by them everywhere: files, Lua, the window).
#[test]
fn names_are_unique() {
let mut seen = std::collections::HashSet::new();
@@ -2005,17 +1861,8 @@ mod tests {
}
}
/// **The registry against the real client's own two files.** Two columns of this table are
/// pure transcription from the 1.12 install, and both are load-bearing in a way nothing else
/// here can see: `runOnUp` — which decides whether a wheel notch delivers a release half at
/// all (1295), and which B265 turned entirely on — and every command's default chords. A
/// hand-typed table drifts; this reads the source of truth instead of trusting the typing.
///
/// `Interface\FrameXML\Bindings.xml` carries the flag, `WTF\DefaultBindings.wtf` the chords.
/// Both are install content, read at runtime, never committed; the test skips (passes)
/// without a client, the house pattern for a real-data test. Running Blizzard's own 234-row
/// file through our [`benilla_ui::bindings_xml`] is a second gate riding along — that is the
/// parser every addon's `Bindings.xml` lands in.
/// Every row's `runOnUp` against the install's `Bindings.xml`, and its default chords against
/// `WTF\DefaultBindings.wtf`.
#[test]
fn the_registry_matches_the_installs_own_bindings() {
let data = benilla_formats::wow_data_or_skip!();
@@ -2033,8 +1880,7 @@ mod tests {
.map(|b| (b.name.as_str(), b.run_on_up))
.collect();
// `bind <CHORD> <COMMAND>`, in the file's own order — which is the order the command's
// Key 1 and Key 2 slots take.
// `bind <CHORD> <COMMAND>`; file order is the Key 1, Key 2 order.
let wtf = String::from_utf8_lossy(
&chain
.read_file("WTF\\DefaultBindings.wtf")
@@ -2063,7 +1909,7 @@ mod tests {
s.run_on_up(),
reference_run_on_up,
"{}: runOnUp disagrees with the install's Bindings.xml — that flag decides \
whether a press of this command delivers a release half (1295)",
whether a press of this command delivers a release half",
s.name
);
let ours: Vec<&str> = [s.d1, s.d2].into_iter().flatten().collect();
@@ -2076,20 +1922,8 @@ mod tests {
}
}
/// **The coverage gate** (decision 1745): every one of the client's 228 live bindings is
/// either in [`SPECS`] or in [`ABSENT`], and never both.
///
/// This is the half [`the_registry_matches_the_installs_own_bindings`] could not see. That
/// test walks OUR rows and checks them against the install, so a command we never wrote down
/// is invisible to it — which is how the keyring, the pet book, the reputation and honor
/// pages, the six action-bar pages and the two vertical multibars all shipped their mechanism
/// and kept their key unbound, some of them for five hundred commits. Walking the INSTALL's
/// rows instead makes an unwritten command a build failure the day it becomes reachable.
///
/// `hidden` rides along: 1.12 marks twelve bindings `hidden="true"` (the nine debug toggles
/// and the three mouse-look ones) and the window never lists them. This client registers none
/// of them, and this asserts that stays true — a hidden row in `SPECS` would be a row the
/// Keybindings page shows and the reference does not.
/// Every one of the client's 228 live bindings is in exactly one of [`SPECS`] and [`ABSENT`],
/// none of the twelve `hidden="true"` ones is in `SPECS`, and `SPECS` keeps the file's order.
#[test]
fn every_1_12_command_is_registered_or_recorded_absent() {
let Some(reference) = install_bindings() else {
@@ -2130,8 +1964,7 @@ mod tests {
);
}
// The table's order IS the Keybindings page's row order, and the module claims it is the
// file's. It was not: TOGGLECHARACTER1/3 sat ahead of TOGGLESPELLBOOK until 1745.
// The table's order is the Keybindings page's row order.
let order: Vec<&str> = reference.iter().map(|b| b.name.as_str()).collect();
let mut at = 0usize;
for s in SPECS {
@@ -2146,14 +1979,8 @@ mod tests {
}
}
/// **The staleness half.** Each [`ABSENT`] row names the Lua globals the reference's own body
/// calls that this client does not define; while at least one is still missing the absence is
/// real. When they are all defined the mechanism has landed and the row is owed to [`SPECS`] —
/// which is precisely the event nothing was watching for.
///
/// "Defined" is read from source, not from a VM: a harness would have to load the whole of
/// FrameXML to answer, and the question here is "has anyone written it yet", which the sources
/// say directly. Same posture as `tests/world_api_wall.rs`.
/// An [`ABSENT`] row whose `needs` are all defined belongs in [`SPECS`]. "Defined" is read
/// from source, not from a VM.
#[test]
fn the_absent_commands_are_still_absent() {
let defined = lua_globals_defined();
@@ -2179,10 +2006,8 @@ mod tests {
}
}
/// The absent table is well formed: unique names, and every row either carries a falsifier or
/// is one of the host-side ones the header calls out. The second half is not a formality — a
/// row with no `needs` can rot silently, so the count of them is pinned and moving it is a
/// deliberate act.
/// Unique names, a reason on every row, and no row without `needs`, which could go stale
/// unnoticed.
#[test]
fn the_absent_table_is_well_formed() {
let mut seen = std::collections::HashSet::new();
@@ -2202,21 +2027,8 @@ mod tests {
);
}
/// **The scanner's own falsifier.** [`the_absent_commands_are_still_absent`] is only a gate
/// while `lua_globals_defined` really sees every kind of definition; a scanner that quietly
/// returned nothing would pass every row forever, which is the exact failure it exists to
/// end. So: one global of each kind that is certainly defined, and one that certainly is not.
///
/// **There are THREE kinds since decision 1751, and this test found the third the hard way.**
/// `ToggleKeyRing` sat in the FrameXML row below and started failing the day the bag windows
/// migrated: it is defined in `ContainerFrame.lua`, which this client no longer ships — it
/// EXECUTES it off the player's own patch chain. The honest repair was not to swap the name
/// for one still in `assets/ui`; it was that the scan had gone blind to a whole store, and
/// blind to it in exactly the direction that matters (1751 §4 makes "ship the stock files off
/// the chain" the default for a NEW window, so the global that retires an ABSENT row —
/// `ToggleBattlefieldMinimap`, say — will appear in neither Rust nor `assets/ui`). So
/// `lua_globals_defined` reads the chain half of the manifest too, and `ToggleKeyRing` stays
/// here as the witness for it.
/// `lua_globals_defined` sees each store it reads and invents nothing; a blind scanner would
/// pass every [`ABSENT`] row.
#[test]
fn the_global_scanner_sees_both_kinds_of_definition() {
benilla_formats::wow_data_or_skip!();
@@ -2227,17 +2039,16 @@ mod tests {
"the Rust scan missed the host registration `{host}`"
);
}
// Each is defined ONLY in `assets/ui` — the reference's own homes for them
// (CharacterFrame.lua, ActionBarFrame.lua, UIParent.lua) are not among the files
// `benilla.toc` sources, so the chain scan below cannot cover for a broken one.
// None of these is in `assets/ui`: `ChangeActionBarPage` is a host registration and the
// other two come from stock CharacterFrame.lua and UIParent.lua off the chain, so this
// loop does not isolate the `assets/ui` scan.
for shipped in ["ToggleCharacter", "ChangeActionBarPage", "ShowUIPanel"] {
assert!(
defined.contains(shipped),
"the assets/ui scan missed `{shipped}`"
"the scan missed `{shipped}`, a host registration or a stock chain global"
);
}
// The chain half needs a client, like every other reader of install content — and the
// two rows above still run without one, so the test keeps its teeth either way.
// Stock ContainerFrame.lua's, sourced off the chain by `benilla.toc`.
if benilla_formats::wow_data().is_some() {
for sourced in ["ToggleKeyRing", "ToggleBag", "OpenAllBags"] {
assert!(
@@ -2253,13 +2064,8 @@ 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.
/// The install's `Bindings.xml` has the shape `benilla_ui::bindings_xml` documents; the
/// commented-out `MOVEVIEW*` family must not register.
#[test]
fn the_installs_bindings_xml_reads_as_the_parsers_header_says() {
let Some(binds) = install_bindings() else {
@@ -2285,7 +2091,7 @@ mod tests {
);
}
/// The install's own `Bindings.xml`, parsed — `None` (and a skipped test) without a client.
/// The install's own `Bindings.xml`, parsed.
fn install_bindings() -> Option<Vec<benilla_ui::bindings_xml::AddonBinding>> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open the 1.12 patch chain");
@@ -2298,25 +2104,11 @@ mod tests {
Some(benilla_ui::bindings_xml::parse(&xml).expect("Blizzard's own file parses"))
}
/// Every Lua global this client defines, read out of its own sources — **all three stores**:
/// the host registrations (`g.set("Name", …)` anywhere in the workspace), our own FrameXML
/// port's function definitions (`function Name(` / `Name = function` inside the `<Script>`
/// blocks of `assets/ui`), and the reference's own FrameXML that decision 1751 executes off
/// the player's installed patch chain.
///
/// The third store is not decoration. Until the bag windows migrated, "the FrameXML this
/// client runs" and "the files in `assets/ui`" were the same set; they are not any more, and
/// 1751 §4 makes the divergence grow — a NEW window ships as the stock files off the chain
/// plus the engine verbs they call, so the global that would retire an [`ABSENT`] row can land
/// without a line changing in either half this scan used to read. `benilla.toc` is the one
/// ordered list of both stores (a manifest entry carrying a path is the reference's file), so
/// it is what the chain half walks. No install ⇒ no chain half, the standard degradation.
///
/// Deliberately syntactic and deliberately generous — a false "defined" fails the gate loudly
/// (it asks for a row that turns out not to work), while a false "missing" would let a landed
/// mechanism stay silent, which is the failure this whole gate exists to end. A reference
/// file's `local function name()` is therefore counted as well; that is the generous direction
/// on purpose.
/// Every Lua global this client defines, read from source: host registrations
/// (`g.set("Name", …)`), function definitions in `assets/ui`'s `<Script>` blocks, and the
/// stock files `benilla.toc` sources off the chain (none without an install). Deliberately
/// generous, `local function` included: a false "defined" fails loudly, a false "missing"
/// would hide a landed mechanism.
fn lua_globals_defined() -> std::collections::HashSet<String> {
fn walk(dir: &std::path::Path, ext: &str, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -2344,8 +2136,7 @@ mod tests {
}
}
/// One Lua-carrying text's own definitions — a `<Script>` block of ours or a reference
/// `.lua` off the chain, the same two shapes either way.
/// The `function Name(` and `Name = function` definitions in one Lua-carrying text.
fn scan_lua(text: &str, names: &mut std::collections::HashSet<String>) {
let mut rest = text;
while let Some(i) = rest.find("function ") {
@@ -2370,10 +2161,8 @@ mod tests {
}
}
/// The `.lua` files an XML document pulls in through `<Script file="…"/>`. Only a script
/// reference ends in `.lua` — a `<Texture file=>` names art — so the extension is the
/// whole test, and the paths come back relative to the document's own directory, which is
/// how the loader resolves them (1186).
/// The `.lua` files an XML document pulls in through `<Script file="…"/>`, relative to the
/// document's directory, as the loader resolves them.
fn script_files(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = text;
@@ -2388,7 +2177,6 @@ mod tests {
out
}
// `crates/benilla-app/` → the workspace root.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
@@ -2403,8 +2191,7 @@ mod tests {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
// `.set("Name"` — the host-registration idiom, over any amount of intervening
// whitespace (rustfmt breaks the long ones onto their own line).
// `.set("Name"`, across any whitespace, as rustfmt breaks long ones onto a new line.
let mut rest = text.as_str();
while let Some(i) = rest.find(".set(") {
rest = &rest[i + ".set(".len()..];
@@ -2427,8 +2214,6 @@ mod tests {
scan_lua(&text, &mut names);
}
// The chain half of the manifest — the reference's own files, read the way the client
// reads them.
let Some(data) = benilla_formats::wow_data() else {
return names;
};
@@ -2441,9 +2226,8 @@ mod tests {
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
// The manifest's own rule for which store an entry names
// (`ui_script::reference_ui::is_chain_entry`): our tree is flat, so a separator is a
// path and a path is the reference's. The bare names were walked above.
// An entry with a path separator is a stock file
// (`ui_script::reference_ui::is_chain_entry`).
.filter(|l| l.contains('\\') || l.contains('/'))
{
let Ok(bytes) = chain.read_file(entry) else {
+17 -38
View File
@@ -1,35 +1,29 @@
//! Binding persistence (decision 0997): `benilla-config/bindings/account.txt` and
//! `benilla-config/bindings/<Realm>-<Char>.txt`, through [`crate::local_state`] like every resident.
//!
//! Format is **command-centric diff-vs-defaults** — one line per command whose keys moved:
//! Binding persistence in `benilla-config/bindings/account.txt` and `<Realm>-<Char>.txt`: one
//! line per command whose keys differ from its defaults.
//!
//! ```text
//! # benilla key bindings (decision 0997)
//! # benilla key bindings
//! bind JUMP F
//! bind MOVEFORWARD W
//! bind TOGGLESHEATH
//! ```
//!
//! `bind <COMMAND> [key...]` replaces that command's whole key list; no tokens = deliberately
//! unbound; a command absent from the file = its registered defaults. This diverges from the
//! reference's full-snapshot `bindings-cache.wtf` deliberately (recorded in 0997): benilla grows
//! commands weekly, and a wholesale snapshot would ship every new command unbound to anyone with
//! a saved file. Tokens are the canonical chord strings, which never contain spaces, so
//! whitespace splitting is safe (`-` and `=` are key tokens, hence no sentinel for "empty" —
//! absence of tokens is the sentinel).
//! `bind <COMMAND> [key...]` replaces the command's key list; no tokens means unbound, an absent
//! command keeps its defaults. Tokens are canonical chord strings and never contain spaces.
//! Deviation: the reference saves a full snapshot (`bindings-cache.wtf`); a diff is stored
//! because a full snapshot would leave every command added later unbound for a saved file.
use std::collections::HashMap;
use super::commands::SPECS;
/// Serialize the live table as the diff file. `snapshot` is `(command, keys)` in registry order
/// ([`benilla_ui::script::UiScript::keybind_snapshot`]'s shape).
/// Serialize the live `(command, keys)` table, in registry order, as the diff file.
pub(crate) fn to_diff(snapshot: &[(String, Vec<String>)]) -> String {
let defaults: HashMap<&str, Vec<&str>> = SPECS
.iter()
.map(|s| (s.name, [s.d1, s.d2].into_iter().flatten().collect()))
.collect();
let mut out = String::from("# benilla key bindings (decision 0997) — diff vs defaults\n");
let mut out = String::from("# benilla key bindings — diff vs defaults\n");
for (name, keys) in snapshot {
let is_default = defaults
.get(name.as_str())
@@ -48,9 +42,8 @@ pub(crate) fn to_diff(snapshot: &[(String, Vec<String>)]) -> String {
out
}
/// Parse a diff file into `(command, keys)` overrides — the shape
/// [`benilla_ui::script::UiScript::seed_binding_set`] takes. Unknown commands are kept (a file
/// from a newer build stays intact through load/save); malformed lines are skipped.
/// Parse a diff file into `(command, keys)` overrides; unknown commands are kept, malformed lines
/// skipped.
pub(crate) fn from_diff(text: &str) -> Vec<(String, Vec<String>)> {
let mut out = Vec::new();
for line in text.lines() {
@@ -71,10 +64,8 @@ pub(crate) fn from_diff(text: &str) -> Vec<(String, Vec<String>)> {
out
}
/// Resolve a diff into the **full** per-command key table (defaults with the overrides applied,
/// steal law honored file-order) — what a stored set seeds. The steal pass matters: a file that
/// binds `W` to JUMP must also strip `W` from MOVEFORWARD even if MOVEFORWARD has no line (older
/// file, new default collision) — one key, one command, always.
/// Resolve a diff into the full key table: defaults, then each override in file order stealing
/// its keys from every other command, so one key always maps to one command.
pub(crate) fn resolve(diff: &[(String, Vec<String>)]) -> Vec<(String, Vec<String>)> {
let mut table: Vec<(String, Vec<String>)> = SPECS
.iter()
@@ -98,12 +89,8 @@ pub(crate) fn resolve(diff: &[(String, Vec<String>)]) -> Vec<(String, Vec<String
}
match table.iter_mut().find(|(n, _)| n == name) {
Some((_, slot)) => *slot = keys.clone(),
// **A name that is not in SPECS is an ADDON's command, and it is kept** (decision
// 1201). Dropping it here is what made an addon binding forget its key every restart:
// the row was written by `to_diff`, read back by `from_diff`, had its chord stolen
// from whoever else held it by the pass above — and was then thrown away, so the
// binding registered at world entry with nothing to restore. `Bindings.txt` is a flat
// list in the reference too; an addon's row is a row like any other.
// A name not in SPECS is an addon's command, registered later at world entry: keep
// its row so the binding has its keys to restore.
None => table.push((name.clone(), keys.clone())),
}
}
@@ -116,7 +103,7 @@ mod tests {
#[test]
fn the_diff_round_trips_and_defaults_stay_silent() {
// All-defaults snapshot → header-only file.
// All defaults: a header-only file.
let snapshot: Vec<(String, Vec<String>)> = SPECS
.iter()
.map(|s| {
@@ -168,8 +155,6 @@ mod tests {
#[test]
fn resolve_steals_across_commands_even_without_a_line_for_the_victim() {
// A file that binds W to JUMP: MOVEFORWARD keeps UP but loses W, with no MOVEFORWARD
// line in the file at all.
let resolved = resolve(&[("JUMP".to_string(), vec!["W".to_string()])]);
let fwd = &resolved.iter().find(|(n, _)| n == "MOVEFORWARD").unwrap().1;
assert_eq!(fwd, &vec!["UP".to_string()]);
@@ -187,13 +172,7 @@ mod tests {
("BOGUSCMD".to_string(), vec!["Q".to_string()]),
]
);
// **A command the registry does not know is KEPT** (decision 1201). It used to be
// dropped after its keys were stolen, which was right for a genuinely bogus line and
// catastrophic for the case that actually occurs: an ADDON's command, whose
// `Bindings.xml` registers at world entry, hours after this file was read. Dropping it
// meant the binding registered with nothing to restore and forgot the player's chord
// every restart (1192 §4). The steal still happens either way, so the file's intent
// ("Q belongs to that command, not STRAFELEFT") holds for the commands we do have.
// An unknown command (an addon's, registered later) is kept, and still steals its key.
let resolved = resolve(&parsed);
let bogus = &resolved.iter().find(|(n, _)| n == "BOGUSCMD").unwrap().1;
assert_eq!(bogus, &vec!["Q".to_string()]);
+81 -196
View File
@@ -1,91 +1,35 @@
//! The **unit blob shadow** — the soft dark oval under every unit (the player, every NPC, every
//! 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 unit blob shadow: the dark oval under every player, NPC and creature, drawn by the
//! reference's `0x6d7920` and rebuilt here on the shared surface-decal projector.
//!
//! **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
//! ring's `0x200122` **+ the liquid receivers** (a gap here: liquid isn't in the
//! [`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`. `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
//! paints over it — which is what [`Rung::SHADOW_SORT`](benilla_world::sky_order::Rung) now
//! expresses, and what it did not until B347: at the old positive rung the shadow was the one
//! thing in the scene a river or a portal effect could not attenuate.
//! - **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 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): 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 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
//! combine, which is exactly the lane's `EffectBlend::Multiply` (`dst × lerp(1, src, α)`).
//! Vertex diffuse is **white** with α = the model's **base** alpha — `CM2Model+0x180`, read
//! through the accessor `0x710ca0` (`fld [ecx+0x180]`) at `0x6d7fd6`, then clamped to `[0,1]`,
//! ×255, `__ftol`, packed `(a<<24)|0x00FFFFFF`. VERIFIED, and it is `+0x180` and not `+0x19c`:
//! over the whole draw every `0x19c` operand is the stack local `[ebp-0x19c]`, and `this` is
//! touched at exactly three `mov ecx,ebx; call` sites (box `0x711a20`, matrix `0x710600`,
//! alpha `0x710ca0`), which makes that list the complete field census.
//! - Draw path: the per-frame model-node pass `0x683dd0` gates at `0x6d78f0` and draws through
//! the selection ring's decal chain (`0x6d7330 → 0x6d6fa0 → 0x6d7480`). Its collector flags
//! `0x2f0122` add liquid receivers to the ring's; `GroundDecalSurface` has none yet, so the
//! shadow lands on terrain and WMO faces only.
//! - Frame slot: phase 1, among the opaque drains (`0x6812c5` in `0x681070`). Per unit the ring
//! ticks first and the shadow draws second (`0x683ec3`), so the multiply darkens the ring. It
//! lands after terrain and WMO and before footprints, M2 opaque, water and both M2 transparent
//! passes, so every transparent paints over it: `Rung::SHADOW_SORT`.
//! - Texture: `Textures\ShadowBlob.blp`, a 32×32 grayscale radial blob under a binary alpha
//! disc. The reference adds a 64×8 trapezoid ramp on a second stage (`0x6d81a0`/`0x6d82d0`)
//! whose combine is untraced; here the ramp is a vertical vertex-alpha fade.
//! - Box (`0x711a20`, `0x6d7920`): the `playableAnimationLookup[0]` sequence's box (Stand, fixed
//! per model, not the playing sequence), clamped into ±5 per axis pre-scale (a cap, never a
//! floor), scaled, yaw-rotated and axis-aligned-bounded. Vertically `+(h/2)` up and
//! `-(5/3)(h/2)` down about the origin. A zero horizontal box draws nothing.
//! - Appearance: multiply (`GL_DST_COLOR/GL_ZERO`, `EffectBlend::Multiply`), white vertices with
//! alpha = the model's base alpha `CM2Model+0x180` (read at `0x6d7fd6`). That carries the
//! appear and despawn fades, the first-person fade and the stealth or ghost aura fade
//! (`0x60d180` drives the same `0x614f80`), but not the M2 colour and texture-weight tracks
//! (`0x707aea`), exactly like `UnitRenderAlpha`. Unlit, no fog, no depth write; the texture is
//! `Rgba8Unorm`, so the multiply runs on raw bytes as the reference's does.
//! - Registration: units, players and corpses cast one; game objects and dynamic objects never do
//! (`0x670e94`, `0x613e10`). The reference's `shadowLOD` toggle is not built: always on.
//! - Deviation: the shadow is gated on `InheritedVisibility`, though the reference draws one under
//! an undrawn body (`0x48161d`), because benilla's exterior-scene election hides units the
//! reference never loads, and their shadows would stay on the floor below.
//!
//! **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
//! `0x614a90` writes `model+0x180 = obj+0x100 · obj+0xf4` unconditionally given a model handle.
//! A stealthed unit's blob shadow dims with its body, on the same 1000 ms cubic.
//!
//! **What does not**: the M2 animated colour / texture-weight tracks. The body's per-batch
//! alpha is `+0x19c · M2Color[i].alpha · M2TextureWeight[j].value` (`0x707aea`–`0x707b33`);
//! this draw reads `+0x180` raw, so an asset-authored transparency track fades the body and
//! leaves its shadow at full strength. [`benilla_world::model_fade::UnitRenderAlpha`] is that
//! same product and stops at the same place.
//!
//! The darkness lives in the texture RGB. Unlit, no fog, no depth write. The texture loads as the default `WorldArt`
//! `Rgba8Unorm`, so the modulate multiplies raw bytes in the gamma lane — the reference's own
//! arithmetic (0161).
//! - **Gating**: the reference's `shadowLOD` cvar {0,1} is the master toggle (default on) — we are
//! always-on; `shadowBias` (default 0.1) is its depth-bias knob — [`SHADOW_DEPTH_BIAS`] plays
//! that role here. No dead/mount/kind test exists on the draw path, and **which** objects
//! 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
//! the v1 policy this lane already ran (every Player/Unit with a built animated model), now a
//! byte-fact rather than a guess.
//!
//! **One benilla divergence, deliberate.** `0x6d78f0`/`0x6d7920` read no visibility state at
//! all — the body's draw flag `[CM2Model+0x50]` comes from `ShouldRender` at `0x48174e`, which
//! is consumed at `0x48161d`, *after* the tick — so the reference can and does draw a shadow
//! under an undrawn body (a unit whose skin composite is still unbaked, `0x477860`). We gate on
//! `InheritedVisibility` anyway, because benilla's exterior-scene election is not a mechanism
//! the reference has and left Tanaris mobs' shadows on the Caverns of Time floor (decision
//! 1277). Keeping that gate is the 1277 call; it is named here so it reads as a choice.
//!
//! One shadow record per unit, its projected triangles rebuilt only when the inputs move
//! ([`ShadowKey`]) and pushed onto the effect stream every shown frame — an idle unit costs a
//! key compare plus one memcpy of its cached slice. (The stream has no per-draw frustum cull;
//! an off-screen shadow's triangles are vertex-clipped GPU-side — dozens of ~50-vert slices,
//! below any ledger line.)
//! Each shadow's triangles are rebuilt only when [`ShadowKey`] moves and copied onto the effect
//! stream every shown frame.
use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
@@ -104,30 +48,25 @@ 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`, created by `0x6d8070`): grayscale
/// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground.
/// The reference's shadow disc, created by `0x6d8070`.
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
/// (`0x6992c0` MAX(−5) / `0x699250` MIN(+5) — a cap on huge authored boxes, never a floor).
/// Each box corner component is clamped into ±5 yd pre-scale (`0x6992c0`, `0x699250`).
const BOX_CLAMP: f32 = 5.0;
/// Degenerate-box epsilon (the reference's `[0x8029d4]` = 2.384e-7): a zero horizontal extent is
/// the no-op exit — no shadow.
/// The reference's degenerate-box epsilon (`[0x8029d4]`).
const DEGENERATE_EPS: f32 = 2.384e-7;
/// MONKEY (night blob): the realtime lane's edge band — mirrors `SHADOW_EDGE_BAND` in
/// `shadow_hook.wgsl`, where the realtime shadow lightens over the last yards of the cascade.
/// The oval fades back IN across the same band, so the handover is a crossfade at both ends.
const SHADOW_EDGE_BAND: f32 = 14.0;
/// One unit's shadow record (a top-level entity — no render components; the cached projection
/// rides the effect stream). Despawned when the owner goes.
/// One unit's shadow record, a top-level entity despawned with its owner.
#[derive(Component)]
struct BlobShadow {
owner: Entity,
}
/// Last frame's rebuild inputs — the projection is redone only when one moves. `surfaces` counts
/// the [`GroundDecalSurface`] colliders: a tile streaming in under a *standing* unit changes it,
/// re-arming the rebuild its stillness would otherwise skip.
/// Last frame's rebuild inputs. `surfaces` counts the decal receivers, so a tile streaming in
/// under a standing unit still re-arms the rebuild.
#[derive(Component, Default)]
struct ShadowKey {
feet: Vec3,
@@ -139,13 +78,12 @@ struct ShadowKey {
shown: bool,
}
/// The cached projection: world-space effect triangles (white × the ramp/fade alpha), pushed
/// onto the stream every shown frame, rebuilt on [`ShadowKey`] change. Empty = hidden.
/// The cached world-space triangles; empty when hidden.
#[derive(Component, Default)]
struct ShadowVerts(Vec<EffectVertex>);
/// The one shadow texture (kept so the census can report the image's load state — a texture
/// that never arrives withholds every shadow draw at the render-side residency gate, silently).
/// The shadow texture, kept so the census can report its load state: a missing texture
/// silently withholds every draw.
#[derive(Resource)]
struct ShadowAssets {
texture: Handle<Image>,
@@ -177,16 +115,13 @@ fn setup_shadow_assets(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.insert_resource(ShadowAssets { texture });
}
/// 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 still open; this is the v1 set (see
/// module docs).
/// Keep one shadow record per player or unit whose animated model has built; despawn the ones
/// whose owner went. Corpses, which the reference also shadows (module docs), get none here.
#[allow(clippy::type_complexity)] // the filtered spawn-gate query, commented inline
fn sync_shadows(
mut commands: Commands,
// A mount child never gets its own decal: its `Transform` is parent-relative (a shadow
// keyed on it would project at the world origin) — the mounted composite casts ONE shadow,
// the unit's, which reads the mount's box while mounted (`update_shadows`, decision 0441).
// A mount child's `Transform` is parent-relative, so it casts none; the rider's shadow reads
// the mount's box instead.
units: Query<
(Entity, &NetEntity),
(
@@ -195,10 +130,8 @@ fn sync_shadows(
),
>,
shadows: Query<(Entity, &BlobShadow)>,
// The reconciler's inputs move only when a unit's animated model arrives or leaves, or a
// unit becomes or stops being a mount child (the `units` filter's two terms); a frame with
// none of those has the same answer it had last frame, and used to pay a set build plus a
// walk of every unit for it.
// The answer changes only when a model or a mount arrives or leaves; any other frame is
// skipped.
grew: Query<(), Added<ModelAnimations>>,
mut shrank: RemovedComponents<ModelAnimations>,
mounted: Query<(), Added<crate::entities::mount::MountBody>>,
@@ -213,7 +146,6 @@ fn sync_shadows(
}
let mut shadowed = EntityHashSet::default();
for (entity, shadow) in &shadows {
// Owner gone or no longer eligible (model torn down) → the decal goes with it.
if units.get(shadow.owner).is_err() {
commands.entity(entity).despawn();
} else {
@@ -250,7 +182,6 @@ fn update_shadows(
ready: Option<Res<CharacterShadowReady>>,
// …and the camera, for the realtime lane's EDGE fade (its cascade ends at `shadowDistance`).
cam_pos: Query<&GlobalTransform, With<WorldCamera>>,
// The unit's render alpha, asked of the unit — see the alpha block below.
unit_alpha: benilla_world::model_fade::UnitRenderAlpha,
owners: Query<
(
@@ -258,9 +189,7 @@ fn update_shadows(
&ModelAnimations,
Has<Embodied>,
Option<&crate::entities::mount::MountChild>,
// …and whether the owner is drawn at all. A body the exterior-scene election sent to
// pass 2 is not in the reference's scene, so it casts nothing (decision 1277). The
// election writes the ROOT's `Visibility`; this is that verdict after propagation.
// The exterior-scene election's verdict on the root, after propagation.
Option<&InheritedVisibility>,
// …and the unit's WMO room. `Some(room)` = indoors — the realtime SUN shadow is gated
// out of interiors, so an INDOOR unit keeps its oval even when `characterShadows` is on
@@ -269,17 +198,12 @@ fn update_shadows(
),
Without<BlobShadow>,
>,
// The mounted box source: the composite's one shadow reads the MOUNT's Stand box at the
// 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 that source is known.
// Mounted, the shadow reads the mount's Stand box at the mount's scale; which box the
// reference uses for a mounted unit is untraced.
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
// target is `benilla_app`; a `benilla::` filter silently matches nothing): how many
// shadows exist and why the hidden ones hid — the first question of any "no shadow under X"
// report, answerable from a log instead of a debugger.
// A once-a-second census of shadows and why the hidden ones hid, at
// `RUST_LOG=benilla_app::blob_shadow=debug` (a `benilla::` filter matches nothing).
mut census_at: Local<f32>,
) {
// MONKEY (moon shadows): a time jump or pending aim must restore the oval in the SAME
@@ -295,15 +219,13 @@ fn update_shadows(
}
let (mut n_total, mut n_shown, mut n_no_owner, mut n_no_clip, mut n_degen, mut n_no_ground) =
(0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
// Counted apart from `n_no_owner`: a body the exterior election sent to pass 2 HAS an owner,
// and folding the two would make the census answer "why did it hide" with a lie. This is the
// instrument's whole job (decision 1283).
// Apart from `n_no_owner`: an undrawn body still has an owner.
let mut n_undrawn = 0u32;
let surface_count = decals.receiver_count();
for (shadow, mut key, mut verts) in &mut shadows {
n_total += 1;
let Ok((unit, anims, is_self, mount_child, drawn, room)) = owners.get(shadow.owner) else {
// sync_shadows despawns next frame; keep it cleared meanwhile.
// `sync_shadows` despawns it next frame.
hide(&mut key, &mut verts);
n_no_owner += 1;
continue;
@@ -330,27 +252,19 @@ fn update_shadows(
continue;
}
}
// An owner that is not drawn casts nothing. The director's report from inside Caverns of
// Time (decision 1277): the exterior election had correctly stopped drawing the Tanaris
// mobs overhead, and their shadows carried on being projected onto the cavern floor,
// because this lane keys off the unit's `Transform` and never asked whether the unit was
// in the scene. The census counts it separately, so "why is there a shadow with no
// creature" is answerable from the log rather than from a debugger.
// The visibility deviation (module docs): an undrawn owner casts nothing.
if !drawn.is_none_or(|v| v.get()) {
hide(&mut key, &mut verts);
n_undrawn += 1;
continue;
}
// Mounted: the box and the extra scale column come from the mount child (the
// `mount_anims` doc above); until its model lands, the rider's own box carries the frame.
// Until the mount's model lands, the rider's own box stands in.
let (anims, extra_scale) = match mount_child.and_then(|mc| mount_anims.get(mc.0).ok()) {
Some((mnet, manims)) => (manims, mnet.scale),
None => (anims, 1.0),
};
// 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.
// The box is `playableAnimationLookup[0]`'s sequence, Stand, fixed per model; `resolve(0)`
// walks the same table, so a Stand-less model gets the reference's substitute.
let stand = catalog.as_deref().map_or(0, |c| anims.resolve(0, &c.0).id);
let clip = anims.find(stand);
let Some(clip) = clip else {
@@ -358,7 +272,7 @@ fn update_shadows(
n_no_clip += 1;
continue;
};
// The box law (see module docs): clamp INTO ±5 pre-scale, scale, yaw-rotate + AA-bound.
// Clamp into ±5 pre-scale, then scale.
let s = (unit.scale.x * extra_scale).max(0.0);
let bmin = clip
.bounds_min
@@ -374,21 +288,8 @@ fn update_shadows(
n_degen += 1;
continue;
}
// **The unit's render alpha, asked of the unit** — the shadow rides the model's fade slot
// (module docs), so it takes the engine's answer rather than reconstructing one.
//
// It used to gather the live `RenderFade`s off this unit's *part* entities and read "no
// part is fading" as `1.0`. That is true of a settled unit and exactly backwards for the
// state a unit spends its whole arrival in: a streamed unit holds `PendingAppearFade` —
// no `RenderFade` anywhere yet — until the world is actually shown, so its parts are
// deliberately invisible while this walk found nothing to attribute and drew the shadow
// **fully opaque**. A dark oval on the ground under no creature for the length of the
// load (2.9 s on a probe login), snapping to zero the instant the ramp armed, then easing
// back in with the body. `UnitRenderAlpha` asks the root, where `UnitAppearFade` tells
// pending from settled — the distinction a part-side walk cannot make.
//
// The self first-person fade comes with it (the reference rides the same slot), which is
// why there is no `is_self` term here any more.
// The unit's render alpha from the root, which also tells a pending appear fade (zero)
// from a settled unit, and carries the first-person fade.
let mut alpha = unit_alpha.get(shadow.owner);
// MONKEY (night blob): the realtime-lane handover rides the SAME alpha the spawn/despawn
// fades do — the oval is a Multiply decal, so scaling its alpha scales its darkening, and
@@ -413,9 +314,8 @@ fn update_shadows(
n_shown += 1;
continue;
}
// Horizontal: the model box's 4 rect corners through the unit's rotation with the
// vertical column dead (`rot × (x, 0, z)`, XZ taken — the byte build zeroes the z-terms),
// then axis-aligned-bounded.
// The four horizontal corners through the rotation with the vertical term zeroed, as
// the reference's build does, then axis-aligned-bounded.
let (mut min_x, mut max_x) = (f32::MAX, f32::MIN);
let (mut min_z, mut max_z) = (f32::MAX, f32::MIN);
for (x, z) in [
@@ -428,8 +328,7 @@ fn update_shadows(
(min_x, max_x) = (min_x.min(w.x), max_x.max(w.x));
(min_z, max_z) = (min_z.min(w.z), max_z.max(w.z));
}
// Vertical about the model origin: `+1.0·(zExt/2)` up, `−(5/3)·(zExt/2)` down (the byte
// constants `[0xcea60c]`/`[0xcea610]`).
// Vertically `+(h/2)` up, `-(5/3)(h/2)` down (`[0xcea60c]`, `[0xcea610]`).
let half_v = (bmax.y - bmin.y) * 0.5;
let frame = DecalFrame {
center: unit.translation,
@@ -449,10 +348,8 @@ fn update_shadows(
&mut verts.0,
&frame,
|p| {
// The trapezoid ramp over the vertical span (the reference's second texture
// stage, `0x6d81a0`: rise x<2, flat, fall x≥10 over x = 12·u). *Interim
// seat*: that the ramp runs vertically is inferred from the box's asymmetric
// vertical reach; the combine wiring is the flagged apitrace item.
// The reference's second-stage ramp (`0x6d81a0`), run vertically: inferred
// from the asymmetric vertical reach, its combine being untraced.
alpha * shadow_ramp((p.y - frame.min_y) / span_v)
},
|x, z| frame.rect_uv(x, z),
@@ -466,10 +363,8 @@ fn update_shadows(
n_no_ground += 1;
}
if census && is_self {
// The census's self row: where the OWN shadow's projection actually went — the
// "shadow missing under me" report's second question (the first is the gate line
// below). Y-extents vs feet split "landed on the surface I stand on" from "fell
// through to a receiver below" in one read.
// Where the own shadow's projection went: vertex y against feet y tells the surface
// stood on from a receiver below.
let (mut y_min, mut y_max) = (f32::MAX, f32::MIN);
let (mut x_min, mut x_max) = (f32::MAX, f32::MIN);
let (mut z_min, mut z_max) = (f32::MAX, f32::MIN);
@@ -481,8 +376,7 @@ fn update_shadows(
z_min = z_min.min(v.pos[2]);
z_max = z_max.max(v.pos[2]);
}
// World-XZ extents of what was actually emitted vs the frame rect: if these disagree,
// the projector inflated/displaced the box (the -8844,669 hunt).
// Emitted XZ extents against the frame rect: a mismatch indicts the projector.
debug!(
"self shadow world span: x [{:.3}, {:.3}] ({:.3}), z [{:.3}, {:.3}] ({:.3}); \
feet ({:.3}, {:.3}), yaw {:.1} deg",
@@ -509,17 +403,14 @@ fn update_shadows(
y_min,
y_max
);
// The -8844,669 hunt's uv probe: a span check alone can't see a DEGENERATE mapping
// (all uv.y at the rim still spans 0..1) — print the actual per-vert uv pairs.
// Per-vertex uvs: a span check cannot see a degenerate mapping.
let uvs: Vec<String> = verts
.0
.iter()
.map(|v| format!("({:.3},{:.3})", v.uv[0], v.uv[1]))
.collect();
debug!("self shadow uvs: {}", uvs.join(" "));
// 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.
// On the reference, HumanFemale's footprint is 0.77x0.74 yd, nearly centred.
debug!(
"self shadow box: bmin {:?} bmax {:?} rect x [{:.3}, {:.3}] z [{:.3}, {:.3}] \
(extent {:.3}x{:.3}, centre offset ({:.3}, {:.3}))",
@@ -537,8 +428,7 @@ fn update_shadows(
}
}
if census && n_total > 0 {
// Not just "loaded": the CONTENT. A white-decoded blob multiplies to a no-op — an
// invisible shadow whose every draw-side reading looks healthy (the -8844,669 hunt).
// The texture's content, not just its load: a white-decoded blob multiplies to nothing.
let tex = shadow_assets.map_or("no-resource".into(), |a| {
images.get(&a.texture).map_or("MISSING".into(), |img| {
let (w, h) = (img.width(), img.height());
@@ -550,8 +440,7 @@ fn update_shadows(
d.get(i..i + 4).map(|p| format!("{p:?}"))
})
.unwrap_or_else(|| "no-data".into());
// The -8844,669 hunt: the whole centre ROW, not one texel — the ink radius and
// alpha reach decide how much of the box the disc visibly fills.
// The whole centre row: how much of the box the disc visibly fills.
if let Some(d) = img.data.as_ref() {
let row: Vec<String> = (0..w as usize)
.map(|x| {
@@ -574,9 +463,8 @@ fn update_shadows(
}
}
/// Push every shown shadow's cached triangles onto the stream — one Multiply draw per unit at
/// the shadow rung (the pre-water decal band's floor, the module header's frame slot), fog off:
/// the reference's shadow pass state.
/// Push every shown shadow onto the stream: one multiply draw per unit at the shadow rung, fog
/// off, the reference's shadow pass state.
fn push_shadows(
assets: Option<Res<ShadowAssets>>,
cam: Query<Entity, With<WorldCamera>>,
@@ -589,8 +477,7 @@ fn push_shadows(
if verts.0.is_empty() {
continue;
}
// Multiply: the modulate decal is its own darkening — the scene light is already in the
// ground it multiplies, which is why the lane's unlit default is right here.
// Unlit: the scene light is already in the ground it multiplies.
let mut batch = draw
.batch(cam, assets.texture.id())
.multiply()
@@ -605,14 +492,13 @@ fn push_shadows(
}
}
/// Clear the record and drop the cache key so the next eligible frame rebuilds from scratch.
/// Clear the record so the next eligible frame rebuilds.
fn hide(key: &mut ShadowKey, verts: &mut ShadowVerts) {
key.shown = false;
verts.0.clear();
}
/// Did any rebuild input move beyond noise? Position/box at a millimetre, rotation at ~0.05°,
/// alpha at under a colour step.
/// Whether an input moved beyond noise: a millimetre, ~0.05°, under one colour step.
fn key_changed(a: &ShadowKey, b: &ShadowKey) -> bool {
const POS_EPS: f32 = 1e-3;
a.feet.distance_squared(b.feet) > POS_EPS * POS_EPS
@@ -648,8 +534,8 @@ fn blob_weight(sun_strength: f32, cam_dist: f32, shadow_range: f32) -> f32 {
(1.0 - sun_strength.clamp(0.0, 1.0) * (1.0 - edge_fade)).clamp(0.0, 1.0)
}
/// The reference's trapezoid alpha ramp (`0x6d81a0`/`0x6d82d0`, diffed bit-exact in wow-re:
/// `x = 12·u` — rise `x<2 → x/2`, flat `2≤x<10 → 1`, fall `x≥10 → (12−x)/2`, clamped at 0).
/// The reference's trapezoid alpha ramp (`0x6d81a0`/`0x6d82d0`): over `x = 12u`, `x/2` below 2,
/// 1 through 10, `(12-x)/2` after.
fn shadow_ramp(u: f32) -> f32 {
let x = 12.0 * u.clamp(0.0, 1.0);
if x < 2.0 {
@@ -665,8 +551,7 @@ fn shadow_ramp(u: f32) -> f32 {
mod tests {
use super::*;
/// The ramp's byte-verified shape: rise to 1 at x=2 (u=1/6), flat through x=10 (u=5/6), fall
/// to 0 at x=12 (u=1).
/// Rise to 1 at u=1/6, flat through u=5/6, fall to 0 at u=1.
#[test]
fn ramp_matches_reference_trapezoid() {
assert_eq!(shadow_ramp(0.0), 0.0);
@@ -746,13 +631,13 @@ mod tests {
assert!((prev - 0.65).abs() < 1e-6);
}
/// The box law: clamp INTO ±5 pre-scale (a cap, not a floor), then scale.
/// The ±5 clamp is a pre-scale cap, not a floor.
#[test]
fn box_clamp_caps_pre_scale() {
let raw = Vec3::new(-7.0, 0.0, 3.0);
let clamped = raw.clamp(Vec3::splat(-BOX_CLAMP), Vec3::splat(BOX_CLAMP));
assert_eq!(clamped, Vec3::new(-5.0, 0.0, 3.0));
// A scale-2 unit's clamped box still doubles — the cap is pre-scale.
// A scale-2 unit's clamped box still doubles.
assert_eq!(clamped * 2.0, Vec3::new(-10.0, 0.0, 6.0));
}
}
+16 -36
View File
@@ -1,44 +1,31 @@
//! 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.
//! The engine-drawn bowstring: bow M2s have no string geometry. The reference's per-frame callback
//! (`0x611ff0`) draws a two-segment line between the `$WTT`/`$WTB` limb-tip markers, its middle
//! vertex at the HandArrow attach while the nock latch (`[+0xd58] & 0x4000`) holds, else the tip
//! midpoint. Drawn here as gizmo lines; the color and width are inferred, since the reference's
//! packed vertex color is not decoded.
//!
//! 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 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
//! bones that bend through the draw. Composing them through the prop's rigid root frame instead
//! (which is all this file could do while the prop rested at bind pose) leaves the chord pinned to
//! the un-bent limb tips while the mesh around it moves: the string would visibly leave the bow.
//! The tips are posed: `$BWP` plays BowPull (160) on the prop and `$BWR` returns it to Stand (0),
//! so the markers ride limb bones that bend through the draw.
use bevy::prelude::*;
use crate::creature_anim::NockLatch;
use crate::entities::BoneAttach;
/// The HandArrow attach id (35 — `0x6121b8`): the string's middle control point while nocked,
/// the same point the nocked-arrow model rides.
/// The HandArrow attach id (35, `0x6121b8`): the string's middle point while nocked.
const HAND_ARROW: u16 = 0x23;
/// Marks a spawned **bow prop root** (the held-item child of the hand joint) whose model authors
/// the `$WTT`/`$WTB` string anchors. Inserted by the held-item attach; despawned with the prop
/// (a sheath change / weapon swap tears the prop down, which is the client's clear too).
/// Marks a bow prop root whose model authors the `$WTT`/`$WTB` anchors; despawned with the prop.
#[derive(Component)]
pub(crate) struct Bowstring {
/// The unit wearing the bow — the HandArrow middle vertex and nock latch live on it.
/// The unit wearing the bow, which carries the HandArrow attach and the nock latch.
pub(crate) owner: Entity,
/// The `$WTT` top / `$WTB` bottom anchors as `(bone, model-local Bevy offset)` — the bone is
/// load-bearing on an animated prop (see the module note): the limb tips move with the draw.
/// The `$WTT` top and `$WTB` bottom anchors as `(bone, model-local offset)`.
pub(crate) top: (u16, Vec3),
pub(crate) bottom: (u16, Vec3),
}
/// Draw every visible bow's string (per frame, post-propagation so the prop/joint frames are
/// this frame's). Two segments: tip → middle → tip.
/// Draws every visible bow's string, tip to middle to tip, from this frame's joint frames.
fn draw_bowstrings(
bows: Query<(
&Bowstring,
@@ -54,30 +41,25 @@ fn draw_bowstrings(
joints: Query<&GlobalTransform>,
mut gizmos: Gizmos,
) {
// A dark waxed-cord tone, inferred: the ref's packed vertex color is not decoded.
// Inferred: the reference'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() {
continue;
}
// The prop's own pose when it flexes (`joints_root` IS this entity, so its
// `GlobalTransform` is the frame `posed_point` wants); its rigid frame otherwise, which is
// the same answer for a bow standing at bind pose.
// The prop's own pose when it flexes (`joints_root` is this entity); its rigid frame
// otherwise.
let tip = |(bone, offset): (u16, Vec3)| {
flex.and_then(|p| p.posed_point(prop, bone, offset))
.unwrap_or_else(|| prop.transform_point(offset))
};
let top = tip(bs.top);
let bottom = tip(bs.bottom);
// The middle control point: the owner's HandArrow attach world position while the nock
// latch holds (the drawn string follows the hand), else the relaxed straight chord.
let middle = owners
.get(bs.owner)
.ok()
.filter(|(_, _, latched)| *latched)
.and_then(|(bones, pose, _)| {
// A pure position read — `posed_point` off the composed pose, no anchor entity
// (decision 1355).
let &(bone, offset) = bones.points.get(&HAND_ARROW)?;
pose.posed_point(joints.get(pose.joints_root).ok()?, bone, offset)
})
@@ -87,9 +69,7 @@ fn draw_bowstrings(
}
}
/// Registers the string drawer after the palette pass (the middle vertex reads the HandArrow
/// joint's frame — its translation is palette-invariant, but same-frame ordering keeps every
/// consumer of joint frames on one side of the rewrite).
/// Registers the string drawer after the palette pass, beside the other joint-frame readers.
pub(crate) struct BowstringPlugin;
impl Plugin for BowstringPlugin {
+83 -260
View File
@@ -1,55 +1,16 @@
//! **Camera shake** — the thump a heavy creature's footfall puts through the camera, the one-off
//! jolt as its body lands, and the jolt a spell effect puts through it (B298; decisions 1540/1849).
//! Camera shake: a heavy creature's footfall, its death thud, and spell effects, all spawned
//! through the reference's one spawner, `AddShake(id, worldPos)` (`0x511d40`).
//!
//! **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: no address-dword reference to it anywhere, and no Lua, console, cinematic or quake
//! path).
//! `CreatureModelData.FootstepShakeSize` (field 11, on the visual footfall tags, not `$FSD`) and
//! `DeathThudShakeSize` (field 12, on `$DTH`) name a `CameraShakes.dbc` preset. `SpellVisualKit`
//! field 14 and the `$SHK` event name a `SpellEffectCameraShakes.dbc` group of up to three.
//!
//! The **creature** pair names a `CameraShakes.dbc` preset **directly**:
//!
//! - **`CreatureModelData.FootstepShakeSize`** (field 11) → fired from the **visual** footfall
//! channel — the per-foot plant tags (`$FL0`/`$FR0`/`$RL0`/`$RR0`…), the same stream
//! [`crate::footprints`] reads. **Not `$FSD`**, which is the sound handler's alone.
//! - **`CreatureModelData.DeathThudShakeSize`** (field 12) → the same table, fired on `$DTH`.
//!
//! The **spell** pair names a `SpellEffectCameraShakes.dbc` **group** — up to three presets at one
//! point ([`benilla_formats::SpellShakeGroup`]):
//!
//! - **`SpellVisualKit` field 14** (`kit+0x38`, the `ShakeID` column) → once per kit play, via
//! [`SpellKitShake`] and [`fire_kit_shakes`], which carries the mechanism and the one deviation.
//! - **the `$SHK` animation event** → its payload *is* a group id, fired ungated at the event's own
//! bone-transformed point — the fired key's own, carried on the event (decision 1904).
//!
//! **`$SHK` is decoded by exactly two handlers**, hanging off the GameObject (typemask `0x20`,
//! `0x5f3e20`) and DynamicObject (`0x40`, `0x5d58c0`) trampolines — the dword `0x4b485324` occurs
//! twice image-wide, and of the six dispatchers `0x7133a0` registers only those two decode it.
//! **`CGUnit_C::HandleAnimEvent` is not one of them**, so a `$SHK` on a creature M2 logs
//! `UNHANDLEDANIMEVENT` and does nothing — which is not academic: CryptLord and Thunderaan both
//! author markers on their death/emerge clips and the reference plays neither. That gate is why no
//! creature shake can be `$SHK`-driven, and it is enforced in [`fire_shakes`].
//!
//! Only 25 of the 430 shipped models carry a footstep shake, and the set is exactly the
//! 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
//!
//! **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`
//! (50 yd)**. The shake sits *outside* the `showfootprints` CVar gate and *outside* the 25 yd gate
//! that follows it, so it reaches twice as far as a footprint decal and does not turn off with
//! them. The position is the planted foot's world point, exactly as the decal derives it.
//!
//! **Emission — the death thud** (`0x625c30`): **no gates at all** — no hover, no ghost, no
//! distance, no CVar — at the unit's own world position.
//!
//! **Per live record, per frame** (`0x511760`/`0x5116e0`):
//! Per live record, per frame (`0x511760`/`0x5116e0`):
//!
//! ```text
//! t = (now − start) + phase // seconds; phase is a TIME pre-roll, not an angle
//! if !(t < duration) → retire
//! A = amplitude / 36 // the DBC column is inches; yards on the wire out
//! t = (now − start) + phase // seconds; phase is a time pre-roll, not an angle
//! if !(t < duration) → retire // a hard cutoff, no taper
//! A = amplitude / 36 // the DBC column is inches
//! d² = |eye − pos|² // pos is snapshotted at spawn
//! if d² > 6400 → contribute nothing (the record survives)
//! if d² > 81 → A *= 0.7^((√d² − 9) / 9)
@@ -57,45 +18,9 @@
//! if shake_type == 1 → a *= exp(−coefficient · t)
//! ```
//!
//! Three corrections to the conventional column map, all from the consumer: **`Phase` is a time
//! pre-roll in seconds**, not an angle — it advances the sine *and* the decay, and shortens the
//! real life to `duration − phase`; **`Duration` is a hard cutoff with no taper**, so a
//! `shake_type == 0` row is cut off mid-swing at full strength; and **`ShakeType` is a one-bit
//! decay switch**, not a type — `== 1` exactly enables the `exp` envelope, and `Coefficient` is
//! that rate in 1/s and is dead data on every `shake_type == 0` row. The decay is base **e**.
//!
//! **What moves.** A pure world-space **translation of the eye** — no rotation, no FOV change; the
//! look-at target is rebuilt as `eye + forward`, so it rides along. `Direction` selects an axis in
//! the **followed unit's body frame**, re-read every frame, which is why turning the player
//! rotates an in-flight horizontal shake:
//!
//! | `direction` | reference (WoW axes) | ours (Bevy) |
//! |---|---|---|
//! | 0 | `(a·cos φ, a·sin φ, 0)` | `a ·` forward |
//! | 1 | `(−a·sin φ, a·cos φ, 0)` | `a ·` left |
//! | 2 | `(0, 0, a)` | `a ·` up |
//!
//! (`bevy = (−wow.y, wow.z, −wow.x)` — decision 0002 — and our yaw about `+Y` equals the WoW
//! facing, so axis 0/1 land exactly on the unit's forward/left.) **Every creature row is
//! `direction = 2`**, so in practice a footstep shake is purely vertical; the other two axes are
//! reachable only from the spell-side table and are implemented for completeness. Axes 0 and 1 are
//! *not* independent — both sum into the horizontal plane.
//!
//! **Combination.** Three slots, one per axis, each keeping the single strongest live shake — same-axis
//! shakes do **not** sum, the losers are dropped outright, and at most three shakes play in a frame.
//! This is what a spell **group** is shaped for: its three slots name the `direction` 0·1·2 members
//! of one preset family, so they land on three different axes and all three contribute. They are
//! slots and not axes, though — group 4 is `15 · 14 · 15`, and the duplicate loses the tie-break.
//! Ties keep the incumbent, and the walk is oldest→newest, so ties keep the **older** record.
//!
//! **Lifetime.** A record holds a *snapshot* position and no reference to its source, so a shake
//! fully outlives the creature that spawned it. The whole evaluation is skipped — offset zero,
//! nothing expiring — while the **followed unit** (the far-sight subject when there is one, our own
//! body otherwise) is **swimming** or riding a **fly-or-swim spline**: the reference's two suspend
//! gates, both of which jump the same block.
//!
//! Not modelled, deliberately: the reference's second wholesale free on `SetTarget(nullptr)`, and
//! its destructor's unlink-without-free (a shipped leak — our `Vec` cannot reproduce it).
//! The result translates the eye along an axis of the followed unit's body frame (`direction` 0
//! forward, 1 left, 2 up), re-read every frame. Each axis keeps only its strongest live shake, so
//! same-axis shakes never sum.
use std::f32::consts::TAU;
@@ -114,34 +39,33 @@ use benilla_protocol::EntityKind;
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
/// Beyond this the record contributes nothing but is **not** retired (`0x5116e9`, `80 yd²`).
/// Beyond 80 yd a record contributes nothing but is not retired (`0x5116e9`).
const CULL_DISTANCE_SQ: f32 = 6400.0;
/// Inside this the shake plays at full authored strength (`9 yd²`).
/// Inside 9 yd a shake plays at full authored strength.
const FULL_DISTANCE_SQ: f32 = 81.0;
/// The falloff's half-life base and its per-9-yd exponent divisor.
/// Beyond 9 yd the amplitude falls by 0.7 per 9 yd.
const FALLOFF_BASE: f32 = 0.7;
const FALLOFF_SPAN: f32 = 9.0;
/// `CameraShakes.Amplitude` is authored in inches; the client scales it at spawn (`0x511d78`).
/// `CameraShakes.Amplitude` is in inches; the client scales it at spawn (`0x511d78`).
const INCHES_TO_YARDS: f32 = 1.0 / 36.0;
/// One live shake: the authored row, where it happened, and when it started.
/// One live shake.
struct LiveShake {
row: CameraShake,
/// World position, **snapshotted at spawn** — the record never looks at its source again.
/// World position, snapshotted at spawn: a shake outlives the unit that spawned it.
pos: Vec3,
/// App-clock seconds at spawn.
start: f32,
}
/// The live shake set and the eye offset it composes for this frame.
/// The live shake set.
#[derive(Resource, Default)]
pub(crate) struct CameraShakes {
live: Vec<LiveShake>,
}
impl CameraShakes {
/// Enqueue a shake at a world point. The row is copied in whole — the DBC is the only source
/// of shape, and nothing downstream re-reads the catalog.
/// Enqueue a shake at a world point.
fn add(&mut self, row: CameraShake, pos: Vec3, now: f32) {
self.live.push(LiveShake {
row,
@@ -150,12 +74,9 @@ impl CameraShakes {
});
}
/// Enqueue a whole `SpellEffectCameraShakes` **group** at a world point — the spell side's
/// only spawn shape (`0x6ecb40`, byte-read 2026-09-02): walk the three slots `+0x4/+0x8/+0xc`
/// in ascending order, skip the zeros, and `AddShake(id, pos)` each. A slot the preset table
/// does not carry is dropped by the same bounds check the reference makes (`0x511d40`'s
/// `cmp ecx,[maxId]`); a group naming the same preset twice (group 4 is `15 · 14 · 15`)
/// enqueues it twice and the duplicate simply loses [`Self::evaluate`]'s strict-`>` tie-break.
/// Enqueue a `SpellEffectCameraShakes` group (`0x6ecb40`): its three slots in order, zeros
/// skipped, a preset the table lacks dropped (`0x511d40`'s bounds check). A slot repeated
/// (group 4 is `15 · 14 · 15`) enqueues twice and the copy loses the tie-break.
fn add_group(
&mut self,
group: &SpellShakeGroup,
@@ -168,25 +89,17 @@ 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`): 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
/// **and expires nothing**: a shake resumes mid-flight on surfacing or landing, rather than
/// having quietly aged out while the camera was not reading it.
/// Retire what has expired and compose this frame's offset. A suspended frame (`0x50ea87`,
/// `0x50ea8b` → `0x50eb01`) yields zero and expires nothing, so a shake resumes afterwards.
fn evaluate(&mut self, eye: Vec3, facing_yaw: f32, now: f32, suspended: bool) -> Vec3 {
if suspended {
return Vec3::ZERO;
}
// Retire on the *unattenuated* clock: distance decides contribution, never lifetime.
// Distance decides contribution, never lifetime.
self.live.retain(|s| s.elapsed(now) < s.row.duration);
// One slot per axis, each holding (compare key, signed value). The walk is oldest→newest
// and the compare is strict, so a tie keeps the older record — the reference's own
// `jne`-skips-the-write shape.
// One (compare key, signed value) slot per axis; walked oldest first with a strict
// compare, so a tie keeps the older record, as the reference does.
let mut slots: [Option<(f32, f32)>; 3] = [None; 3];
for s in &self.live {
let Some((key, value)) = s.sample(eye, now) else {
@@ -194,15 +107,15 @@ impl CameraShakes {
};
let axis = s.row.direction as usize;
let Some(slot) = slots.get_mut(axis) else {
continue; // direction ≥ 3 writes nothing (and corrupts the reference's frame)
continue; // direction ≥ 3 writes nothing
};
if slot.is_none_or(|(best, _)| key > best) {
*slot = Some((key, value));
}
}
// Axis 0/1 are the followed unit's forward/left, 2 is world up (module doc's table). Our
// yaw about +Y is the WoW facing, so forward is exactly `(−sin, 0, −cos)`.
// Axes 0/1 are the followed unit's forward/left, 2 is world up; our yaw about +Y is the
// WoW facing.
let (sin, cos) = facing_yaw.sin_cos();
let forward = Vec3::new(-sin, 0.0, -cos);
let left = Vec3::new(-cos, 0.0, sin);
@@ -212,16 +125,13 @@ impl CameraShakes {
}
impl LiveShake {
/// Seconds into the shake, including `phase` — which is a **time pre-roll**, so it advances
/// both the sine and the decay and shortens the real life to `duration − phase`.
/// Seconds into the shake, `phase` included, so the real life is `duration − phase`.
fn elapsed(&self, now: f32) -> f32 {
(now - self.start) + self.row.phase
}
/// 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 — the stored slot the reference compares at `0x5118a5`.
/// This record's `(compare key, signed offset)` at `eye`; the key is the distance-attenuated
/// amplitude, which 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 {
@@ -233,7 +143,7 @@ impl LiveShake {
}
let t = self.elapsed(now);
let mut value = amp * (TAU * self.row.frequency * t).sin();
// `shake_type == 1` exactly — the one-bit decay switch, base e, rate in 1/s.
// `shake_type == 1` exactly: base-e decay, `coefficient` in 1/s.
if self.row.shake_type == 1 {
value *= (-self.row.coefficient * t).exp();
}
@@ -247,13 +157,8 @@ impl Plugin for CameraShakePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CameraShakes>()
.add_systems(Startup, load_shakes.after(AssetSet::Open))
// Present, like the footprints and the footstep sounds: the same event stream, after
// the frame's animation drive and transforms have settled the foot bones.
//
// Capture-gated in step with the applier ([`crate::player`] schedules that one). The
// pairing is not cosmetic: the applier is what retires expired records, so an emitter
// still running while it is gated off would push a record per footfall that nothing
// ever reaps — a slow leak across a long capture.
// Capture-gated with the applier (in `crate::player`), which is what retires
// records: an emitter running without it would leak a record per footfall.
.add_systems(
Update,
(fire_shakes, fire_kit_shakes)
@@ -313,34 +218,27 @@ fn fire_shakes(
let thud = &ev.ident == b"$DTH";
let shk = &ev.ident == b"$SHK";
if !thud && !shk && footfall_side(&ev.ident).is_none() {
continue; // `$FSD` is the sound handler's; only the VISUAL channel shakes
continue; // `$FSD` is the sound handler's; only the visual channel shakes
}
let Ok((net, transform)) = units.get(ev.entity) else {
continue;
};
if shk {
// `$SHK`: the payload is a **group** id, and the tag is decoded by exactly two
// handlers — the GameObject (`0x5f3e20`, typemask 0x20) and DynamicObject
// (`0x5d58c0`, 0x40) dispatchers. `CGUnit_C::HandleAnimEvent` decodes no `$SHK`
// (the dword occurs twice image-wide), so a creature's marker is inert and this gate
// is what keeps it that way: CryptLord and Thunderaan both author one on their
// death/emerge clips, and the reference plays neither.
// `$SHK` names a group; only the GameObject (`0x5f3e20`) and DynamicObject
// (`0x5d58c0`) handlers decode it, so on a creature it does nothing.
if !matches!(net.kind, EntityKind::GameObject | EntityKind::DynamicObject) {
continue;
}
let Some(group) = shakes.0.group(ev.data) else {
continue; // a payload the group table lacks — the reference bounds-checks too
continue; // a payload the group table lacks; the reference bounds-checks too
};
// **No gates at all** — no distance, visibility, CVar or state test (the sibling
// `$DSL` arm and the footstep path both have them; this one has none). The position is
// the event's own bone-transformed point, not the object's origin.
// No gates; at the event's own bone-transformed point, not the object's origin.
let at = ev.pos.unwrap_or_else(|| transform.translation());
live.add_group(group, &shakes.0, at, now);
continue;
}
// The shake reads the ROOT unit's own model row — a mount's row is never stored there, so
// a rider on a kodo gets the kodo's *footprints* and not its shake (`0x607a00` writes only
// the decal fields). The root is also where the state gates live.
// The root unit's own model row and state: a rider gets its mount's footprints but not
// its shake (`0x607a00` writes only the decal fields).
let mut root = ev.entity;
while let Ok(child_of) = parents.get(root) {
root = child_of.parent();
@@ -356,17 +254,18 @@ fn fire_shakes(
creatures.footstep_shake(d)
}
}) else {
continue; // the overwhelming majority of models shake nothing
continue; // most models shake nothing
};
let Some(row) = shakes.0.get(id) else {
continue;
};
if thud {
// No gates whatsoever, and the unit's own world position — not a bone.
// The death thud (`0x625c30`): no gates, at the unit's own position, not a bone.
live.add(*row, transform.translation(), now);
continue;
}
// The footstep's three state gates, in the reference's order.
// The footstep gates in the reference's order (`0x5fbf70`): hover, stealth, ghost, then
// 50 yd from the camera, outside the `showfootprints` gate.
if movement.is_some_and(|m| m.flags & move_flags::HOVER != 0) {
continue;
}
@@ -375,10 +274,8 @@ fn fire_shakes(
continue;
}
}
// The planted foot: the FIRED key's own point, resolved once by the scanner exactly as
// the kernel snapshots it (decision 1904), not a by-4CC re-find of the marker table. The
// difference is real — 75 shipped models author some 4CC more than once at different
// points — and it is the same quantity the decal derives.
// The fired key's own point, as the decal derives it: 75 shipped models author some
// 4CC more than once.
let foot = ev.pos.unwrap_or_else(|| transform.translation());
if footfall_culls(eye, foot) {
continue;
@@ -387,26 +284,12 @@ fn fire_shakes(
}
}
/// Fire the camera shake a spell-visual kit's field 14 names — the **spell-side producer**
/// (decision 1849), the counterpart of `crate::sound::spell`'s kit-sound route.
/// Fire the shake group a spell-visual kit's field 14 names, once per kit play, with no stage test
/// and no cancel (`0x620e11`, or `0x60f4e6` when the play created no effect node).
///
/// One shake per kit play, unconditionally: the reference reaches it from the first-created effect
/// node's one-time arm pass (`0x620e11`, gated on that node's flags snapshot carrying bit `0x10`)
/// and falls back to the kit tail (`0x60f4e6`) only when the play created no node at all. Neither
/// site tests the stage, and there is no cancel path — a shake outlives its kit.
///
/// **One deliberate deviation, and it is the whole reason this doc paragraph exists.** The
/// reference's primary site passes `&node+0x48` — a position field the bone-attached node
/// constructor `0x61fdd0` **never writes**, and which the site that would write it (`0x62101d`)
/// fills twelve instructions *later*, for the sound. So in the real client a kit whose first
/// created node is bone-attached shakes at `(0, 0, 0)` and is culled by the 80 yd cut: **26 of the
/// 58 shipped shake kits are silently dead**, including every summon (kit 138) and Goblin Sapper
/// 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 (`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.
/// The shake spawns at the kit play's position. The reference reads `&node+0x48` before
/// `0x62101d` writes it, so a bone-attached first node (`0x61fdd0`) shakes at the origin and is
/// culled, silencing 26 of the 58 shipped shake kits; that read-before-write is not copied.
fn fire_kit_shakes(
mut events: MessageReader<SpellKitShake>,
time: Res<Time>,
@@ -430,67 +313,33 @@ fn fire_kit_shakes(
SpellKitShake::PlayAt { pos, group } => (group, pos),
};
let Some(row) = shakes.0.group(group) else {
continue; // a kit naming a group the table lacks — none ship, the bounds check is the
// reference's own (`0x6ecb4d`)
continue; // a group the table lacks; the reference bounds-checks too (`0x6ecb4d`)
};
live.add_group(row, &shakes.0, at, now);
}
}
/// The applier's read of the followed unit: its facing (the shake's body frame), its move flags
/// (the swim half of the reference's skip) and its live path (the spline half).
/// The followed unit's facing, move flags and live path.
type FollowedUnit = (
&'static Transform,
Option<&'static MovementState>,
Option<&'static Spline>,
);
/// Is the whole shake block skipped this frame? The reference's two suspend gates, OR'd — both
/// `jmp` past the same block (`0x50ea87` / `0x50ea8b` → `0x50eb01`), on the **followed unit**.
///
/// **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`], `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"** (`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
/// walk, all of which install the same component — does not suspend anything.
/// The reference's two suspend gates on the followed unit (`0x50ea87`, `0x50ea8b`): swimming
/// (move flag `0x200000`), or a live, not-done spline (`[[unit+0x118]+0xa4]`) carrying `0x200`,
/// the shared fly-or-swim bit (`0x616cb0`). A ground spline such as Charge does not suspend.
fn suspended(mv: Option<&MovementState>, spline: Option<&Spline>) -> bool {
mv.is_some_and(|m| m.flags & move_flags::SWIMMING != 0) || spline.is_some_and(|s| !s.grounded)
}
/// Add this frame's shake to the camera's seated eye.
/// Add this frame's shake to the seated eye.
///
/// Filtered on [`WorldCamera`], never on `Camera3d`: the portrait booths are `Camera3d`s too, and a
/// bare `Camera3d` query here would shake an off-screen booth — the same trap that once yanked the
/// booths to the scenario eye and blanked every portrait. `WorldCamera` and `FlyCam` sit on the one
/// entity `control` seats, which is the entity this must add to.
///
/// Runs **after** the camera is seated, which is what makes the falloff honest: `control` rewrites
/// the base pose every frame, so the transform this reads is the un-shaken eye rather than last
/// frame's shaken one. A zero offset writes nothing at all, preserving the camera's bit-equality
/// no-op gate (decision 1362) — a still camera stays bit-stable and its propagation stays quiet.
///
/// **The body frame and both suspend gates come from the FOLLOWED unit** — `[cam+0x88/0x8c]` in
/// the reference, never `0x468550` (the active player). Ordinarily they are the same object; under
/// far sight (Mind Vision, Eye of Kilrogg, and possession, which rides the same field) they are
/// not, and the camera orbits the subject while our body walks on somewhere else. Reading our own
/// body there would frame the shake on the wrong facing and suspend it on the wrong unit's swim
/// state. [`ViewSubject`] already resolves exactly this object for the rig.
///
/// The reference additionally requires the followed object to be **TYPEMASK_UNIT**
/// (`0x50e90d`–`0x50e92c`) before reading its `CMovement` at all. That falls out here: the
/// fallback is our own body, and a resolved far-sight subject with no [`MovementState`] and no
/// [`Spline`] — a GameObject or a DynamicObject — reads as "not suspended", which is what a
/// unit-only gate on a non-unit yields.
/// Filtered on [`WorldCamera`], never `Camera3d`: the portrait booths are `Camera3d`s too. Runs
/// after the camera is seated, so it reads the un-shaken eye, and a zero offset writes nothing so
/// a still camera stays bit-stable. The body frame and suspend gates come from the followed unit
/// (`[cam+0x88/0x8c]`, the far-sight subject when there is one), never the active player; a
/// non-unit subject reads as not suspended, as the reference's unit-only gate (`0x50e90d`) does.
pub(crate) fn apply_camera_shake(
mut live: ResMut<CameraShakes>,
time: Res<Time>,
@@ -521,7 +370,7 @@ pub(crate) fn apply_camera_shake(
mod tests {
use super::*;
/// `CameraShakes.dbc` row **1** — the Ancient Protector's / kodo's footstep, verbatim.
/// `CameraShakes.dbc` row 1, the kodo's footstep, verbatim.
const FOOTSTEP_1: CameraShake = CameraShake {
id: 1,
shake_type: 1,
@@ -532,7 +381,7 @@ mod tests {
phase: 0.06,
coefficient: 1.0,
};
/// Row **2** — the Ancient of Lore/War, the giants and the dragons. Amplitude 7.0.
/// Row 2, the giants' and dragons' footstep.
const FOOTSTEP_2: CameraShake = CameraShake {
amplitude: 7.0,
..FOOTSTEP_1
@@ -549,41 +398,35 @@ mod tests {
at(row, Vec3::ZERO).evaluate(Vec3::X * d, 0.0, t, false).y
}
/// `Phase` is a **time pre-roll in seconds**, not an angle: at `t = 0` the sine is already
/// advanced to `2π·f·phase`. Row 1 opens at `sin(2π·3·0.06) = +0.905`, so a footstep kicks the
/// eye UP first — the verdict's own observable.
/// Row 1 opens at `sin(2π·3·0.06) = +0.905`, so a footstep kicks the eye up first.
#[test]
fn phase_is_a_time_preroll_and_the_first_kick_is_upward() {
let expected = (2.0 / 36.0) * (TAU * 3.0 * 0.06).sin() * (-0.06f32).exp();
let got = vertical(FOOTSTEP_1, 0.0, 0.0);
assert!((got - expected).abs() < 1e-6, "{got} vs {expected}");
assert!(got > 0.0, "the first kick is UP, not down: {got}");
// Read as an angle instead, the opening sample would be sin(0.06) ≈ 0.06 — 15× smaller.
let as_angle = (2.0 / 36.0) * 0.06f32.sin();
assert!(got > as_angle * 10.0, "phase must not be read as an angle");
}
/// `Phase` also shortens the real life: the record retires at `elapsed + phase >= duration`.
#[test]
fn phase_shortens_the_life() {
let mut s = at(FOOTSTEP_1, Vec3::ZERO);
// 0.33 s in: 0.33 + 0.06 = 0.39 < 0.4, still alive.
// 0.33 + 0.06 = 0.39 < 0.4: alive.
assert!(s.evaluate(Vec3::ZERO, 0.0, 0.33, false).y != 0.0);
assert_eq!(s.live.len(), 1);
// 0.35 s in: 0.41 >= 0.4 — gone, a full 0.05 s before its nominal duration.
// 0.35 + 0.06 = 0.41 >= 0.4: gone.
assert_eq!(s.evaluate(Vec3::ZERO, 0.0, 0.35, false), Vec3::ZERO);
assert!(s.live.is_empty(), "retired at duration − phase");
}
/// `Duration` is a hard cutoff with **no taper** — a `shake_type == 0` row is still at full
/// authored strength on its last frame. (The creature rows all decay; this is the spell shape.)
#[test]
fn duration_is_a_cutoff_not_a_taper() {
let undecayed = CameraShake {
shake_type: 0,
phase: 0.0,
duration: 1.0,
frequency: 0.25, // a quarter-cycle at t = 1 → sine near its peak
frequency: 0.25, // a quarter cycle at t = 1: the sine's peak
..FOOTSTEP_1
};
let last = vertical(undecayed, 0.0, 0.999);
@@ -599,7 +442,6 @@ mod tests {
);
}
/// The decay is base **e** at `coefficient` 1/s, and only when `shake_type == 1`.
#[test]
fn the_decay_switch_is_one_bit() {
let t = 0.2;
@@ -620,8 +462,6 @@ mod tests {
);
}
/// Full strength inside 9 yd; `0.7^((d−9)/9)` beyond; **nothing past 80 yd — but the record
/// survives**, so walking back into range resumes it.
#[test]
fn the_distance_falloff_culls_without_retiring() {
let near = vertical(FOOTSTEP_2, 0.0, 0.0);
@@ -641,28 +481,24 @@ mod tests {
assert_eq!(s.live.len(), 1, "culled, not retired");
}
/// Same-axis shakes do **not** sum — the strongest wins outright — and the key is the
/// distance-attenuated amplitude, so a tie keeps the OLDER record.
#[test]
fn same_axis_shakes_do_not_sum() {
let mut s = CameraShakes::default();
s.add(FOOTSTEP_1, Vec3::ZERO, 0.0); // amplitude 2
s.add(FOOTSTEP_2, Vec3::ZERO, 0.0); // amplitude 7 — wins
s.add(FOOTSTEP_2, Vec3::ZERO, 0.0); // amplitude 7, wins
let both = s.evaluate(Vec3::ZERO, 0.0, 0.0, false).y;
let alone = vertical(FOOTSTEP_2, 0.0, 0.0);
assert!(
(both - alone).abs() < 1e-6,
"the loser is dropped, not added: {both} vs {alone}"
);
// A tie keeps the incumbent, which is the older — the reference's jne-skips-the-write.
let mut t = CameraShakes::default();
t.add(FOOTSTEP_1, Vec3::ZERO, 0.0);
t.add(FOOTSTEP_1, Vec3::X * 40.0, 0.0); // same row, further away ⇒ strictly smaller key
t.add(FOOTSTEP_1, Vec3::X * 40.0, 0.0); // same row, farther: a smaller key
assert_eq!(t.evaluate(Vec3::ZERO, 0.0, 0.0, false).y, alone / 3.5);
}
/// `Direction` is an axis in the followed unit's **body frame**: 2 is up, 0 is forward, 1 is
/// left — so turning the player rotates an in-flight horizontal shake.
/// 2 is up, 0 is forward, 1 is left, so turning rotates an in-flight horizontal shake.
#[test]
fn direction_selects_the_body_frame_axis() {
let up = at(FOOTSTEP_1, Vec3::ZERO).evaluate(Vec3::ZERO, 0.0, 0.0, false);
@@ -691,8 +527,7 @@ mod tests {
assert!(l.y.abs() < 1e-7 && l.z.abs() < 1e-6 && l.x < 0.0, "{l:?}");
}
/// The three spell rows a `SpellEffectCameraShakes` group names, one per axis — group 1's
/// `4 · 5 · 6`, verbatim.
/// Group 1's presets `4 · 5 · 6`, one per axis, verbatim.
const SPELL_4: CameraShake = CameraShake {
id: 4,
shake_type: 0,
@@ -717,7 +552,7 @@ mod tests {
..SPELL_4
};
/// A catalog holding the three spell presets plus the two groups the tests below name.
/// The three spell presets and the groups the tests below name.
fn spell_catalog() -> CameraShakeCatalog {
CameraShakeCatalog::default()
.with_row(SPELL_4)
@@ -728,21 +563,18 @@ mod tests {
id: 1,
slots: [4, 5, 6],
})
// Group 3's shape: one populated slot, two zeros.
// Group 3's shape: one slot, two zeros.
.with_group(SpellShakeGroup {
id: 3,
slots: [6, 0, 0],
})
// A group naming a preset the table lacks, beside one it has — the reference's own
// bounds check drops the first and keeps the second.
// A preset the table lacks, beside one it has.
.with_group(SpellShakeGroup {
id: 9,
slots: [4, 999, 0],
})
}
/// A **group** is the spell side's whole spawn shape: three slots walked in order, zeros
/// skipped, each landing on its own axis — so one group play moves the eye on all three.
#[test]
fn a_group_spawns_one_shake_per_populated_slot() {
let table = spell_catalog();
@@ -750,14 +582,14 @@ mod tests {
s.add_group(table.group(1).unwrap(), &table, Vec3::ZERO, 0.0);
assert_eq!(s.live.len(), 3, "one record per populated slot");
// A quarter-second in, all three axes are moving: 4 → forward, 5 → left, 6 → up.
// 4 → forward, 5 → left, 6 → up.
let at = 0.25;
let offset = s.evaluate(Vec3::ZERO, 0.0, at, false);
assert!(
offset.z != 0.0 && offset.x != 0.0 && offset.y != 0.0,
"{offset:?}"
);
// And each axis carries exactly its own preset's value — the slots do not sum.
// Each axis carries only its own preset's value.
assert_eq!(
offset.y,
vertical(SPELL_6, 0.0, at),
@@ -765,8 +597,7 @@ mod tests {
);
}
/// Zeros are skipped and a slot the preset table does not carry is dropped — the reference's
/// own `test/je` and its `cmp ecx,[maxId]`. Neither is an error; both just contribute nothing.
/// The reference's own `test/je` and `cmp ecx,[maxId]`.
#[test]
fn a_group_skips_zero_slots_and_unknown_presets() {
let table = spell_catalog();
@@ -780,9 +611,7 @@ mod tests {
assert_eq!(dangling.live[0].row.id, 4);
}
/// The shipped groups 4 and 7 name the **same preset twice** (`15 · 14 · 15`), which is the
/// proof the three slots are slots and not axes. Both records spawn; the duplicate loses the
/// strict-`>` tie-break, so the axis reads exactly as it would with one.
/// The shipped groups 4 and 7 name a preset twice (`15 · 14 · 15`).
#[test]
fn a_duplicate_slot_spawns_but_never_doubles_the_offset() {
let table = spell_catalog();
@@ -805,7 +634,6 @@ mod tests {
);
}
/// A `direction` the reference cannot index writes nothing (rather than panicking on our side).
#[test]
fn an_out_of_range_direction_contributes_nothing() {
let bad = CameraShake {
@@ -818,8 +646,6 @@ mod tests {
);
}
/// A suspended frame bypasses the whole block: zero offset, and **nothing expires** — the
/// shake resumes on surfacing (or on landing off a taxi).
#[test]
fn swimming_freezes_rather_than_retires() {
let mut s = at(FOOTSTEP_1, Vec3::ZERO);
@@ -829,9 +655,6 @@ mod tests {
assert!(s.live.is_empty(), "and it retires the moment we surface");
}
/// The two suspend gates are independent and either one alone suspends — and a **ground**
/// spline (a charge, a knockback, a fleeing walk) is not one of them: only spline flag
/// `0x200`, the shared fly-or-swim bit, is.
#[test]
fn either_gate_suspends_and_a_ground_spline_does_not() {
let swimming = MovementState {
+57 -162
View File
@@ -1,56 +1,18 @@
//! `WOW_DEPTH` — **what depth actually won this pixel, and how far away is it?**
//! `WOW_DEPTH`: reads back the depth that won a pixel in the opaque pass, and how far away it is.
//!
//! The last unread link in B38. There, two surfaces of the Far Watch Post tower trade places: the
//! awning is nearer, writes depth, compares `GreaterEqual`, is not discarded, is not culled, keeps
//! the same mesh/material/texture every frame, and is submitted into `AlphaMask3d` at a stable draw
//! position on every single frame (decisions 0662, 0665, 0667) — and on some frames the plank behind
//! it wins the pixel anyway. Every one of those facts was established on the **CPU** side. None of
//! them says what value the GPU wrote into the depth buffer, which is the one thing that decides the
//! pixel and the one thing we could not read.
//! `WOW_DEPTH="<x>,<y>[;<x>,<y>…]"` logs, per frame, the raw reverse-Z value at each screenshot
//! pixel and the surface's distance both along the pixel's ray (comparable with `WOW_PICK`'s hit
//! distances) and to the camera plane. `WOW_DEPTH_AT=<secs>` (default 20) and
//! `WOW_DEPTH_COUNT=<n>` (default 1) shape the sampling like the screenshot burst and the ray pick.
//! Pixels are used as given: the depth texture is allocated in physical pixels.
//!
//! So: `WOW_DEPTH="<x>,<y>[;<x>,<y>…]"` copies the view's depth texture back after the main pass and
//! logs, per frame, the raw reverse-Z value at each pixel and **where that puts the surface** — both
//! as a distance along the pixel's ray and as the perpendicular distance to the camera plane, because
//! those are different numbers and only the first is comparable with a ray cast.
//! `WOW_DEPTH_AT=<secs>` (default 20) / `WOW_DEPTH_COUNT=<n>` (default 1) shape the sampling like the
//! screenshot burst and the ray pick, so the three line up frame for frame.
//! `WOW_DEPTH_QUADS=<bone>[,<bone>…]` (empty value = every quad emitter) samples a grid inside
//! each live particle quad's projected corners instead, and logs the fraction that survives the
//! depth test and how deep the occluder sits in front, in yards. Frames with no live quad are
//! skipped and not counted.
//!
//! **Run it together with `WOW_PICK` at the same pixels.** This probe answers *what depth won*; the
//! ray pick answers *what is standing there* — every hit along the ray with its distance. Reading the
//! won distance against the hit distances is what turns a number into "**whose** depth won": if a
//! frame's depth matches hit 1's distance rather than hit 0's, the nearer surface never wrote depth
//! there, and no argument about draw order or culling survives that. Splitting the two keeps each
//! probe single-purpose, and because the geometry is static the pick's distances hold across the
//! whole burst.
//!
//! Coordinates are **screenshot pixels** — the same space `benilla-visual` reports boxes in and
//! `WOW_PICK` takes — and here they are used directly, because the depth texture is allocated in
//! physical pixels. (`WOW_PICK` has to divide by the window scale factor; its ray cast works in
//! logical units. Same input space, different reason.)
//!
//! ## `WOW_DEPTH_QUADS` — the same reading, taken at a particle quad's OWN pixels
//!
//! A hand-written pixel list is the wrong instrument for a *moving* subject. B16's eye glow is two
//! additive quads a few dozen pixels across, riding an animated bone: name a pixel and by the frame
//! the readback lands the quad is somewhere else, and a grid coarse enough to catch it samples the
//! head, the ground and the neighbouring unit instead (that mis-measurement is retracted).
//!
//! `WOW_DEPTH_QUADS=<bone>[,<bone>…]` (empty value = every quad emitter) reads the emitter's **live
//! vertex buffer** — the four corners `expand_quads` actually wrote, the ones the GPU is about to
//! rasterize — projects them with the frame's own matrices, and samples the depth buffer on a
//! bilinear grid *inside that quad*. What comes back is the number the offline model predicts and
//! the reference measures: the **surviving area fraction** of the quad under `GreaterEqual`, plus
//! how deep the winning surface sits in front of it, in yards. No camera hunting, no pixel guessing,
//! and no way to accidentally measure a different surface.
//!
//! Frames with no live quad are skipped entirely (no copy, no frame counted), so the burst lands on
//! the frames where the subject exists rather than on a wall clock.
//!
//! **MSAA must be off** (`WOW_MSAA=off`). A multisampled depth texture cannot be copied to a buffer
//! at all, and there is no single "the" depth at a pixel to report if it could — there are four. The
//! probe refuses rather than reporting one of them, because a number that looks like the measurement
//! you wanted, taken slightly wrong, is how this bug has already produced four confident wrong
//! answers (0667).
//! MSAA must be off (`WOW_MSAA=off`): a multisampled depth texture cannot be copied and has no
//! single depth per pixel, so the probe refuses.
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
use bevy::ecs::query::QueryItem;
@@ -88,9 +50,7 @@ impl Plugin for DepthProbePlugin {
);
return;
}
// Quad mode arms at once: the subject is a particle pool that does not exist until its
// model spawns, and the frame gate below (report only frames that HAVE quads) is a
// sharper window than any wall clock the caller could guess.
// Quad mode arms at once: frames without a live quad are skipped anyway.
let at = std::env::var("WOW_DEPTH_AT")
.ok()
.and_then(|v| v.parse().ok())
@@ -128,26 +88,15 @@ impl Plugin for DepthProbePlugin {
Render,
(
prepare_staging.in_set(RenderSystems::PrepareResources),
// The node encodes the copy inside the graph; by the time the graph has run and
// submitted, the staging buffer holds it and can be mapped.
// The node encodes the copy in the graph; after submit, staging maps.
read_depth.after(RenderSystems::Render),
),
)
.add_render_graph_node::<ViewNodeRunner<DepthReadbackNode>>(Core3d, DepthReadbackLabel)
// Between the opaque pass (which draws `Opaque3d` *and* `AlphaMask3d`) and the
// transmissive/transparent ones. That is the depth as it stood when the opaque fight was
// decided, which is the question — see [`DepthReadbackNode`] for what reading it later
// gets you instead. The retained static pass draws BEFORE the opaque pass (2016), so
// its walls are in this read too; while it ran after, the graph left the two
// unordered and a probe could read a wall-less depth on some frames.
//
// `WOW_DEPTH_AFTER=1` moves the copy to AFTER the transparent pass — deliberately
// measuring the thing the placement note warns about: the depth the transparent pass
// itself wrote. B38 first observed that phantom ("5–8 yd at pixels where the ray cast
// finds the tower at 32–43 yd … from something that tracks the camera") and never named
// it; B16's eye quads lose a depth compare that the opaque-pass buffer says they win,
// so the same writer is now the suspect. Before-vs-after at the same pixels names its
// depth, and its depth names it.
// After the opaque pass (`Opaque3d` and `AlphaMask3d`), before the transmissive one.
// The retained static pass draws before the opaque pass, so its walls are in the read.
// `WOW_DEPTH_AFTER=1` copies after the transparent pass instead, to read the depth the
// transparent pass itself wrote.
.add_render_graph_edges(
Core3d,
if std::env::var_os("WOW_DEPTH_AFTER").is_some() {
@@ -167,8 +116,7 @@ impl Plugin for DepthProbePlugin {
}
}
/// The pixels to read and the sampling window. Extracted to the render world as a plain clone (a
/// handful of coordinates once per sampled frame).
/// The pixels to read and the sampling window.
#[derive(Resource, Clone)]
struct DepthWatch {
pixels: Vec<(u32, u32)>,
@@ -192,24 +140,19 @@ struct QuadWatch(Option<Vec<u16>>);
#[derive(Clone, Copy)]
struct QuadProbe {
bone: u16,
/// Index within the emitter's mesh — quads are written oldest-first, so 0 is the smallest,
/// dimmest particle and the last is the one born this frame (the big, bright one that decides
/// whether the glow reads at all).
/// Index within the emitter's quads, written oldest-first: the last was born this frame.
index: u32,
/// The four corners in physical pixels, in `expand_quads`' own vertex order.
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 (`0x7b2a50`'s plain billboard gives all four
/// corners one depth).
/// The corners' mid NDC depth; `dspread` is their spread, which the reference's plain
/// billboard (`0x7b2a50`) keeps at zero.
dquad: f32,
dspread: f32,
/// The quad centre's distance to the camera plane, yards — the unit the burial is reported in.
/// The quad centre's distance to the camera plane, yards.
viewz: f32,
}
/// This frame's live quads, in `$WOW_DEPTH_QUADS` scope. Empty on every frame in every run that
/// did not ask for the mode.
/// This frame's live quads, in `$WOW_DEPTH_QUADS` scope.
#[derive(Resource, Clone, Default)]
struct QuadProbes(Vec<QuadProbe>);
@@ -220,14 +163,9 @@ impl ExtractResource for QuadProbes {
}
}
/// Project every in-scope emitter's live quads into pixels, once a frame.
///
/// Deliberately reads the **shared effect stream** (0732 P1), not the simulation: these are the
/// vertices the draw will consume, so a quad that was mis-built (collapsed, mis-billboarded,
/// left at last frame's anchor) is measured as itself rather than as what the sim intended. Runs
/// after `BillboardPlace`, the set that fills the stream, so the read is this frame's. An
/// emitter's CHILD-pool quads ride its draw records too (same `main_entity`), attributed to the
/// parent's bone — the old per-mesh read never saw them at all.
/// Project every in-scope emitter's live quads into pixels, once a frame, from the shared effect
/// stream `BillboardPlace` fills, so each quad is measured as drawn. Child-pool quads share the
/// emitter's draw records and count under its bone.
fn collect_quads(
watch: Res<QuadWatch>,
mut probes: ResMut<QuadProbes>,
@@ -245,8 +183,7 @@ fn collect_quads(
let Some(vp) = camera.physical_viewport_size() else {
return;
};
// The frame's own matrices, the same pair `depthdump` projects with — so a quad's `dquad` here
// and its `dquad` there are the same number, and the two logs can be read against each other.
// The frame's own matrices, the same pair `depthdump` projects with.
let clip_from_world = projection.get_clip_from_view() * cam_tf.to_matrix().inverse();
for (entity, emitter) in &emitters {
if !bones.is_empty() && !bones.contains(&emitter.bone()) {
@@ -298,8 +235,7 @@ fn collect_quads(
}
}
/// `"60,61"` → the bone scope; an empty or absent value means every quad emitter. `None` when the
/// variable is unset at all, which is what turns the whole mode off.
/// `"60,61"` → the bone scope; an empty value means every quad emitter, an unset one `None`.
fn parse_bones(spec: Option<&str>) -> Option<Vec<u16>> {
Some(
spec?
@@ -309,9 +245,7 @@ fn parse_bones(spec: Option<&str>) -> Option<Vec<u16>> {
)
}
/// Marks the one view whose depth to read. The depth texture is cached per *render target*, so the
/// world camera and the UI camera on the same window share it — but picking a view by viewport size
/// would be picking by coincidence, and this bug is made of numbers taken slightly wrong.
/// Marks the one view whose depth to read; the UI camera shares the world camera's depth texture.
#[derive(Component, Clone, Copy, ExtractComponent)]
struct DepthProbeView;
@@ -323,10 +257,8 @@ struct DepthStaging {
height: u32,
}
/// Wait for the sampling window, then mark the world camera: opt its depth texture into `COPY_SRC`
/// (the default is `RENDER_ATTACHMENT` alone, which cannot be copied), and refuse the whole probe if
/// MSAA is on. Checking the live `Msaa` component rather than `$WOW_MSAA` means the refusal tracks
/// what the renderer is actually doing, not what the environment asked for.
/// Once the sampling window opens, opt the world camera's depth texture into `COPY_SRC` and mark
/// it; the live `Msaa` component being anything but off disables the probe.
fn arm(
mut watch: ResMut<DepthWatch>,
time: ProbeClock,
@@ -373,8 +305,7 @@ fn prepare_staging(
}
let Ok(depth) = view.single() else { return };
let size = depth.texture.size();
// `Depth32Float` is 4 bytes a texel, and a buffer copy's row stride must be 256-aligned — so the
// stride is the aligned *byte* count, which is not the aligned pixel count times four.
// `Depth32Float` is 4 bytes a texel; a buffer copy's row stride is 256-byte aligned.
let bytes_per_row = RenderDevice::align_copy_bytes_per_row(size.width as usize * 4) as u32;
if staging.is_some_and(|s| s.bytes_per_row == bytes_per_row && s.height == size.height) {
return;
@@ -394,17 +325,8 @@ fn prepare_staging(
#[derive(RenderLabel, Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct DepthReadbackLabel;
/// Copies the depth texture aside **right after the opaque pass** — the moment the question is about.
///
/// Placement is the whole correctness of this probe, and getting it wrong is quiet. Read at the end of
/// the main pass instead and the numbers come back 5–8 yd at pixels where the ray cast finds the tower
/// at 32–43 yd, with a distance even at pixels that have **no** geometry along the ray: by then the
/// *transparent* pass has written depth of its own, from something that tracks the camera. Those were
/// stable, plausible, reproducible numbers about the wrong thing — which is worse than no probe,
/// because it looks like a measurement.
///
/// `MainOpaquePass` draws `Opaque3d` and then `AlphaMask3d`, which is exactly the pair the B38 fight is
/// between, so this is the depth that decided it.
/// Copies the depth texture right after the opaque pass. Read at the end of the main pass instead,
/// it also holds depth the transparent pass wrote, from something that tracks the camera.
#[derive(Default)]
struct DepthReadbackNode;
@@ -424,12 +346,11 @@ impl ViewNode for DepthReadbackNode {
) else {
return Ok(());
};
// `read_depth` counts the frames; the copy is cheap but not free, so stop with it.
// `read_depth` counts the frames; stop copying once the burst is done.
if !watch.armed || world.resource::<DepthFramesRead>().0 >= watch.count {
return Ok(());
}
// Quad mode with nothing live this frame: no copy, and `read_depth` will not count it —
// the burst spends its frames on the ones that have the subject in them.
// Quad mode with nothing live this frame: no copy, and `read_depth` does not count it.
if watch.pixels.is_empty()
&& world
.get_resource::<QuadProbes>()
@@ -438,14 +359,13 @@ impl ViewNode for DepthReadbackNode {
return Ok(());
}
let size = depth.texture.size();
// `COPY_SRC` only lands on the texture allocated *after* `arm` patched the camera, so the
// first armed frame still has the old one. Skip it rather than trip wgpu validation.
// `COPY_SRC` lands only on the texture allocated after `arm`, so the first armed frame
// still has the old one.
if !depth.texture.usage().contains(TextureUsages::COPY_SRC) {
return Ok(());
}
// Depth-stencil formats reject partial copies (wgpu-core `validate_texture_copy_range`), so
// the whole texture goes across even though we want a handful of pixels. At one debug frame
// each, the simple thing and the correct thing are the same.
// the whole texture goes across.
render_context.command_encoder().copy_texture_to_buffer(
TexelCopyTextureInfo {
texture: &depth.texture,
@@ -471,7 +391,7 @@ impl ViewNode for DepthReadbackNode {
}
}
/// How many frames [`read_depth`] has reported — read by the graph node, which cannot hold a `Local`.
/// How many frames [`read_depth`] has reported; a resource because the graph node reads it.
#[derive(Resource, Default)]
struct DepthFramesRead(u32);
@@ -491,8 +411,7 @@ fn read_depth(
if !watch.armed || read.0 >= watch.count {
return;
}
// Mirrors the node's quad-mode skip: no copy was encoded, so there is nothing to read and this
// frame does not spend one of the burst's slots.
// Mirrors the node's quad-mode skip: nothing was copied, and the frame is not counted.
let quads = quads.map(|q| q.0.clone()).unwrap_or_default();
if watch.pixels.is_empty() && quads.is_empty() {
return;
@@ -507,17 +426,14 @@ fn read_depth(
let size = depth.texture.size();
let slice = staging.buffer.slice(..);
slice.map_async(MapMode::Read, |_| {});
// Block. A probe run is not gameplay, and a frame's numbers are worth nothing if they arrive
// attached to a later frame's index.
// Block, so a frame's numbers never arrive under a later frame's index.
if let Err(e) = device.poll(bevy::render::render_resource::PollType::wait_indefinitely()) {
error!("depth: poll failed: {e}");
return;
}
let frame = read.0;
read.0 += 1;
// The projection the frame was actually drawn with, once per burst. Stated rather than assumed:
// the reported distances are only as good as this matrix, and an instrument whose calibration is
// invisible is one whose numbers cannot be audited later.
// The projection the frame was drawn with, once per burst: the distances derive from it.
if frame == 0 {
info!(
"depth: {}x{} view, clip_from_view P₂₂ {} P₃₂ {} P₀₀ {} P₁₁ {}",
@@ -543,10 +459,8 @@ fn read_depth(
let at = (y * staging.bytes_per_row + x * 4) as usize;
let d = f32::from_le_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]);
match view_point(&view_from_clip, ndc_of(x, y, size.width, size.height), d) {
// Both distances, because they are not the same number and the difference is 15% at
// the edge of the frame: `along` is measured along the ray (what `WOW_PICK` reports,
// so this is the one to compare), `z` is the perpendicular distance to the camera
// plane (what the depth buffer natively encodes).
// Along the ray (what `WOW_PICK` reports) and to the camera plane (what depth
// encodes); they differ by 15% at the frame edge.
Some(p) => info!(
"depth#{frame} ({x}, {y}): {d:.9} = {:.4} yd along the ray ({:.4} yd view z)",
p.length(),
@@ -563,17 +477,12 @@ fn read_depth(
staging.buffer.unmap();
}
/// Samples in each direction across a quad's own area — 16×16 = 256 tests, fine enough that the
/// fraction is stable to well under a percent against the offline model's 48×48 and 64×64 grids,
/// cheap enough to run on every quad of a pool.
/// Samples per side across a quad's own area: 16x16, stable to under a percent.
const QUAD_GRID: usize = 16;
/// Run one quad's depth contest and log it.
///
/// Reverse-Z, `GreaterEqual` (the particle material takes Bevy's default compare): the fragment
/// survives iff `dquad >= dbuffer`. Sampling walks the quad's own `(u, v)` by bilinear interpolation
/// of its four projected corners, so every sample is inside the quad by construction — including a
/// spun quad, whose screen AABB is up to 41 % larger than the quad itself.
/// Run one quad's depth contest and log it. Reverse-Z with Bevy's default `GreaterEqual`: a
/// fragment survives iff `dquad >= dbuffer`. Samples interpolate the four projected corners, so
/// each lies inside the quad even when it is spun.
fn report_quad(
frame: u32,
q: &QuadProbe,
@@ -583,8 +492,7 @@ fn report_quad(
height: u32,
) {
let (mut passed, mut total) = (0usize, 0usize);
// Reverse-Z: the LARGEST buffer value is the NEAREST surface, so `dmax` is the closest thing
// standing in the quad's way and `dmin` the furthest.
// Reverse-Z: `dmax` is the nearest occluder, `dmin` the furthest.
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
let mut cleared = 0usize;
for iy in 0..QUAD_GRID {
@@ -619,8 +527,7 @@ fn report_quad(
);
return;
}
// `dquad` and the buffer values live on the same reverse-Z curve, so the ratio converts either
// to yards without re-deriving the projection: view z scales as 1/d.
// On the reverse-Z curve view z scales as 1/d, so the ratio to `dquad` converts to yards.
let yd = |d: f32| q.viewz * q.dquad / d;
let (near, far) = (yd(dmax), yd(dmin));
info!(
@@ -648,17 +555,8 @@ fn ndc_of(x: u32, y: u32, width: u32, height: u32) -> Vec2 {
)
}
/// Unproject a pixel's depth back to where it is in **view space**, in yards.
///
/// It has to be the whole point, not just the depth: a depth value alone linearises to the distance
/// to the camera *plane*, while a ray cast measures along the *ray*, and off-axis those differ by
/// `1/cos θ` — 15% at the edge of a 45° frame, six yards at this bug's range. Comparing the two
/// without converting is the same axis mix-up that mis-measured B38's surface gap twice (0665), and
/// it is 100× the gap the readback exists to resolve. So unproject the actual pixel and hand back the
/// point; the caller reports both lengths.
///
/// Going through the inverse of the live matrix, rather than a reverse-Z formula, means the reading
/// cannot silently disagree with the projection the frame was drawn with.
/// Unproject a pixel's depth through the live matrix to its view-space point, in yards. The whole
/// point is needed: off axis, the ray length exceeds the plane distance by `1/cos θ`.
fn view_point(view_from_clip: &Mat4, ndc: Vec2, d: f32) -> Option<Vec3> {
let p = *view_from_clip * Vec4::new(ndc.x, ndc.y, d, 1.0);
// Behind the camera or at infinity (a reverse-Z clear reads 0 ⇒ w 0) means nothing drew here.
@@ -667,8 +565,7 @@ fn view_point(view_from_clip: &Mat4, ndc: Vec2, d: f32) -> Option<Vec3> {
.filter(|v| v.is_finite() && v.z < 0.0)
}
/// `"x,y;x,y"` → pixels. Malformed pairs are dropped with a warning rather than failing the run:
/// a typo in one coordinate of a list of eight should not cost a capture.
/// `"x,y;x,y"` → pixels; a malformed pair is dropped with a warning.
fn parse_pixels(spec: &str) -> Vec<(u32, u32)> {
spec.split(';')
.filter(|s| !s.trim().is_empty())
@@ -729,7 +626,6 @@ mod tests {
#[test]
fn off_axis_the_ray_is_longer_than_the_perpendicular_distance() {
// The bug this test exists for: reporting view-space z as if it were the ray-cast distance.
// Straight ahead the two agree; at the frame edge they must not.
let inv = proj().inverse();
let d = depth_of(46.0);
@@ -759,9 +655,8 @@ mod tests {
#[test]
fn the_awning_and_the_plank_are_thousands_of_ulps_apart() {
// The whole instrument rests on this: at the B38 pin the two surfaces are 1.4 cm apart
// perpendicular, and a readback can only name the winner if that gap survives `f32`.
// If this ever fails, the probe cannot answer the question it exists for.
// Two measured surfaces 1.4 cm apart perpendicular: the readback names the winner only if
// that gap survives `f32`.
let (awning, plank) = (46.0253f32, 46.0897f32);
let (da, dp) = (depth_of(awning), depth_of(plank));
let ulps = ((da.to_bits() as i64) - (dp.to_bits() as i64)).abs();
File diff suppressed because it is too large Load Diff
+23 -47
View File
@@ -1,7 +1,5 @@
//! The `fxview` fixture's **driver** — the half of the effect-viewer instrument that stands the
//! subject up in the world (the request, its knobs and the camera are [`super`]'s). It lives here
//! because a fixture's driver belongs with its fixture: `entities::spell_fx` is gameplay, and
//! gameplay never depends on the capture harness.
//! The `fxview` fixture's driver: stands the effect-viewer's subject up in the world; the
//! request, its knobs and the camera are [`super`]'s.
use bevy::prelude::*;
@@ -15,20 +13,15 @@ use crate::entities::spell_fx::{
attach_effect_visuals, EffectHost, FxMaterials, FxTintAnims, SpellFx, FALLBACK_SPAN,
};
/// The `fxview` unit lane's synthetic guid (`WOW_FX_DISPLAY`) — a high-word `0xF130` creature guid
/// like the wire's, in a serverless capture where nothing else claims one.
/// The unit lane's synthetic guid (`WOW_FX_DISPLAY`): a wire-shaped `0xF130` creature guid.
const FXVIEW_UNIT_GUID: u64 = (0xF130u64 << 48) | 0xFC0FEE;
/// The `fxview` GameObject lane's synthetic guid (`WOW_FX_GO`) — the wire's `0xF110` high word,
/// which is what `crate::net` reads a GO's identity out of.
/// The GameObject lane's synthetic guid (`WOW_FX_GO`): the `0xF110` high word `crate::net` reads
/// a GO's identity from.
const FXVIEW_GO_GUID: u64 = (0xF110u64 << 48) | 0xFC0FEE;
/// Drive the `fxview` capture fixture (see [`super::FxViewRequest`]) — the effect-
/// viewer instrument: once the capture driver arms it (scene settled), spawn a root at the
/// fixture point, create the model's [`SpellFx`] cache entry, attach the full visual set
/// through the same [`attach_effect_visuals`] body the game uses, and (for missiles) fly the
/// root along its facing so trails extend. Inert outside fxview captures (the request resource
/// only exists then).
/// Drives the `fxview` fixture once armed: spawns a root, attaches the model through the game's
/// own [`attach_effect_visuals`], and flies a missile along its facing so trails extend.
pub(crate) fn drive_fx_view(
req: Option<Res<FxViewRequest>>,
state: Option<ResMut<FxViewState>>,
@@ -58,11 +51,8 @@ pub(crate) fn drive_fx_view(
};
let root = *state.root.get_or_insert_with(|| {
let mut pos = benilla_assets::coords::wow_to_bevy(req.at.unwrap_or(FXVIEW_POS));
// `WOW_FX_DISPLAY`: the UNIT lane. Spawn the live component set a streamed creature gets
// (`net::apply`'s, the same one the `vplates` fixture's wolf uses) and let the ordinary
// unit pipeline build the model — so what this shoots is a creature, not a model, and
// every unit-only term (tag alpha, the fade gate, anim LOD, the emitters' sequence host)
// is in the picture. Always seated on the terrain: a creature stands on the ground.
// `WOW_FX_DISPLAY`, the unit lane: the component set `net::apply` gives a streamed
// creature, so the unit pipeline builds it with every unit-only term; seated on terrain.
if let Some(display) = req.display {
if let Some(hit) = spatial.cast_ray(
pos,
@@ -87,7 +77,7 @@ pub(crate) fn drive_fx_view(
(22, 100), // UNIT_FIELD_HEALTH
(28, 100), // UNIT_FIELD_MAXHEALTH
(34, 60), // UNIT_FIELD_LEVEL
(35, 35), // UNIT_FIELD_FACTIONTEMPLATE — friendly, so no combat pose
(35, 35), // UNIT_FIELD_FACTIONTEMPLATE: friendly, no combat pose
],
)),
Transform::from_translation(pos)
@@ -96,12 +86,9 @@ 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 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.
// `WOW_FX_GO`, the GameObject lane: a placed GO animates through `crate::go_anim`'s state
// machine (`0x5f3cb0`), not the effect pool. The descriptor carries only display, TYPE_ID
// and STATE, the fields the machine reads; seated on terrain.
if let Some(display) = req.go {
if let Some(hit) = spatial.cast_ray(
pos,
@@ -133,9 +120,7 @@ pub(crate) fn drive_fx_view(
))
.id();
}
// `WOW_FX_GROUND=1`: seat the fixture ON the terrain — a ground-anchored effect's flat
// quads render as projected surface decals and need ground inside their vertical slab.
// The scene settled before arming, so the streamed tile's collider is there to hit.
// `WOW_FX_GROUND=1`: a ground-anchored effect's quads decal onto terrain inside their slab.
if req.ground {
if let Some(hit) = spatial.cast_ray(
pos,
@@ -156,8 +141,7 @@ pub(crate) fn drive_fx_view(
))
.id()
});
// A missile only trails in motion: fly the root along its model-forward (local −Z).
// WOW_FX_TURN keeps the fixture rotating — the attached-model heading-since-birth fan.
// A missile only trails in motion: fly along model-forward (local -Z); `WOW_FX_TURN` spins it.
if req.fly > 0.0 || req.turn != 0.0 {
if let Ok(mut t) = transforms.get_mut(root) {
let fwd = t.rotation * -Vec3::Z;
@@ -168,11 +152,8 @@ pub(crate) fn drive_fx_view(
}
}
}
// The one-pass reap (the game's completion callback, `fx_attach`): a discrete kit
// instance dies at exactly one pass of sequence 0 and its emitters DRAIN — the fixture
// mirrors it so a capture past the span shows the truth. `WOW_FX_HOLD=1` previews a
// persistent hold instead (reaped by its spell edge in game, so no clock here).
// The one-pass reap is an effect-instance rule; a unit — or a placed GameObject — stands there.
// The game's one-pass reap (`fx_attach`): a one-shot kit instance dies after one pass of
// sequence 0. Not for `WOW_FX_HOLD=1` (reaped by its spell in game) or a unit or GO.
if !req.hold && !state.expired && req.display.is_none() && req.go.is_none() {
if let Some(at) = state.attached_at {
let span = fx
@@ -187,9 +168,7 @@ pub(crate) fn drive_fx_view(
}
}
if state.attached_at.is_none() {
// The unit and GameObject lanes have no attach step — the entity pipeline builds the model
// on its own. Their "age" is therefore seconds since the entity appeared, which is what the
// animation phase is measured in.
// The unit and GO lanes have no attach step: their age counts from the entity's spawn.
if req.display.is_some() || req.go.is_some() {
state.attached_at = Some(time.elapsed_secs());
return;
@@ -204,14 +183,11 @@ pub(crate) fn drive_fx_view(
root,
&fx.models[&req.model_path],
time.elapsed_secs(),
true, // the fixture plants at a world point — ground quads decal onto the terrain
// The fixture previews kit effects, which are attached models — but it hangs on
// nothing (there is no host model in a preview), so its pool keeps the drain.
true, // planted at a world point: ground quads decal onto the terrain
// No host model in a preview, so the pool keeps the drain.
EffectHost { parent: None },
// `WOW_FX_HOLD=1` previews a PERSISTENT instance, so it must show the persistent
// lifecycle — birth then `Hold` — or the instrument would report a freeze the game
// does not have. Without it the fixture previews a one-shot, which runs its birth once
// and is reaped by the fixture's own span clock below.
// `WOW_FX_HOLD=1` shows the persistent lifecycle (birth then `Hold`); otherwise a
// one-shot, reaped by the span clock above.
Some(if req.hold {
FxStage::State
} else {
@@ -225,7 +201,7 @@ pub(crate) fn drive_fx_view(
},
&ibps,
&mut palettes,
None, // the fixture previews a kit effect on its model's own `Stand`
None, // a kit effect on its model's own `Stand`
) {
state.attached_at = Some(time.elapsed_secs());
}
+20 -60
View File
@@ -1,8 +1,5 @@
//! The LIVE probe shot — one screenshot (or an adjacent-frame burst) of a NORMAL connected run,
//! with the two validity gates that make its output *evidence*: the death gate (a dead/ghost
//! avatar renders the whole world through the death filter) and the subject gate
//! (`WOW_SHOT_REQUIRE` — a frame that cannot contain the reported subject is not a measurement of
//! it). Split from `probes` when the gates outgrew it; the sibling probe one-shots stay there.
//! The live probe shot: a screenshot or adjacent-frame burst of a connected run, gated on a live
//! avatar (a ghost renders through the death filter) and on the subject being in frame.
use bevy::prelude::*;
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
@@ -14,43 +11,22 @@ use crate::names::NameCache;
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use benilla_world::view::WorldCamera;
/// How far the subject gate's WARN looks when listing what *is* nearby — wide enough to catch the
/// "right room, wrong wing" mis-aim even when `WOW_SHOT_REQUIRE_DIST` is tight.
/// How far the subject gate's warning looks when listing what is nearby.
const REQUIRE_NEARBY_RANGE: f32 = 100.0;
/// Seconds between subject-gate WARNs while waiting. Frequent enough to be read live, rare enough
/// that a log grep isn't wading through it.
/// Seconds between subject-gate warnings while waiting.
const REQUIRE_REWARN_SECS: f32 = 5.0;
/// The LIVE probe shot (`WOW_LIVE_SHOT=<png>`, delay via `WOW_LIVE_SHOT_AT` seconds, default 12):
/// one screenshot of the primary window on a NORMAL connected run — the "what does the live scene
/// actually look like" instrument (server GameObjects, event spawns, NPCs — everything the
/// server-less harness deliberately excludes). The app keeps running after the save; pair with
/// `WOW_USER`/`WOW_CHAR` and an outer `timeout` for unattended probes. Unlike
/// [`super::CapturePlugin`], nothing is pinned — the shot shows the scene as a player would see it.
/// The live probe shot (`WOW_LIVE_SHOT=<png>`, after `WOW_LIVE_SHOT_AT` seconds, default 12): the
/// primary window of a connected run, nothing pinned; the app keeps running after the save.
///
/// **`WOW_LIVE_SHOT_COUNT=<n>` makes it a burst** — `n` shots written as `<stem>-000.png`,
/// `<stem>-001.png`, … , spaced by `WOW_LIVE_SHOT_EVERY` seconds (**default 0 = one per frame**,
/// i.e. *adjacent* frames). One shot still writes the bare path, so every existing invocation is
/// unchanged.
/// `WOW_LIVE_SHOT_COUNT=<n>` makes a burst of `<stem>-000.png`, ... spaced `WOW_LIVE_SHOT_EVERY`
/// seconds (default 0, adjacent frames), so a flicker from a parked camera (`WOW_PROBE_CAM`) can
/// be measured with `benilla-visual flicker <dir>`.
///
/// The burst exists because **a flicker is a temporal artefact and a single frame cannot show one**
/// (decision 0653). "This object's textures flicker" was un-diagnosable with the instruments we had:
/// the reporter's stills prove nothing, and grading a live run by eye is exactly the loop rule 7
/// forbids. Adjacent frames from a *parked* camera ([`crate::player`]'s `WOW_PROBE_CAM`) turn it
/// into arithmetic: `benilla-visual flicker <dir>` collapses the burst to the envelope of what
/// would not hold still, and the lit pixels are the defect.
///
/// **`WOW_SHOT_REQUIRE=<name>` is the SUBJECT gate** (substring, case-insensitive; range via
/// `WOW_SHOT_REQUIRE_DIST`, default 60): no shot fires until a unit with that name is inside the
/// viewport and range — the same contract as the death gate below, because a frame that cannot
/// contain the reported subject is not a measurement of it either. Ten banshee runs were once read
/// as "can't reproduce" with the banshee never in frame, and a voidwalker crop-negative was a model
/// that had wandered out of a fixed window (decision 0705). While the subject is missing the gate
/// WARNs with what *is* nearby — names and distances — so a mis-aimed run re-aims itself instead of
/// ending as a silent blank; a run that exits with 0 shots written names its reason in the log.
/// The unit's feet anchor decides "in frame", and in frame does not mean unoccluded — the gate
/// closes the aim trap, not line-of-sight.
/// `WOW_SHOT_REQUIRE=<name>` (case-insensitive substring; range `WOW_SHOT_REQUIRE_DIST`, default
/// 60) holds every shot until a unit so named has its feet in the viewport and in range, and warns
/// with what is nearby while it waits. In frame does not mean unoccluded.
pub(crate) struct LiveShotPlugin;
impl Plugin for LiveShotPlugin {
@@ -89,9 +65,7 @@ impl Plugin for LiveShotPlugin {
}
}
/// [`LiveShotPlugin`] state: the output path, how many shots the burst wants, their spacing
/// (`0` = one per frame), how many have gone out, and when the next one may — seeded to
/// `WOW_LIVE_SHOT_AT`, so the first-fire delay and the burst spacing are the same clock.
/// [`LiveShotPlugin`] state; `next_at` starts at `WOW_LIVE_SHOT_AT`, then steps by `every`.
#[derive(Resource)]
struct LiveShot {
out: String,
@@ -104,8 +78,7 @@ struct LiveShot {
require_dist: f32,
}
/// `stem-007.png` for shot 7 of a burst — the bare path when the burst is one shot, so the
/// single-shot filename (which existing probes and docs name literally) never moves.
/// `stem-007.png` for shot 7 of a burst; the bare path for a single shot.
fn burst_path(out: &str, index: u32, count: u32) -> String {
if count <= 1 {
return out.to_string();
@@ -116,16 +89,8 @@ fn burst_path(out: &str, index: u32, count: u32) -> String {
}
}
/// Fire the live screenshot(s) once the startup delay has elapsed — one per frame by default, so a
/// burst samples *adjacent* frames and a per-frame instability has nowhere to hide.
///
/// **Refuses to fire while the avatar is dead or a ghost.** `preflight` already reports it, but a
/// single WARN in a four-hundred-line log is not a gate: a ghost renders the whole world through the
/// death filter, so every image in the burst is a desaturated wash and *every* measurement correlated
/// against it — pixel series, hotspot runs, depth readback, the phase census — silently describes the
/// filter instead of the scene. That cost a whole B38 session: eight bursts and three retracted
/// mechanisms measured on a corpse, because the images still looked like a plausible frame. A loud
/// no-op is worth far more than a burst that has to be recognised as garbage after the fact.
/// Fires the live shots once the delay has elapsed. Refuses while the avatar is dead or a ghost:
/// the world then renders through the death filter, and every frame would measure the filter.
#[allow(clippy::type_complexity)] // one Bevy system's full input set
fn fire_live_shot(
mut shot: ResMut<LiveShot>,
@@ -163,11 +128,8 @@ fn fire_live_shot(
return;
}
}
// The SUBJECT gate — same contract as the death gate above: a frame that cannot contain the
// reported subject is not a measurement of it, and a "clean" burst of such frames is the
// costliest false negative a live run produces (decision 0705). Every shot of a burst
// re-passes the gate, so a subject that wanders off mid-burst pauses the burst instead of
// padding it with blanks.
// The subject gate: every shot of a burst re-passes it, so a subject that wanders off pauses
// the burst.
if let Some(want) = shot.require.as_deref() {
let Ok((cam, cam_pose)) = camera.single() else {
return;
@@ -175,13 +137,11 @@ fn fire_live_shot(
let cam_tf = GlobalTransform::from(*cam_pose);
let viewport = cam.logical_viewport_size().unwrap_or(Vec2::ZERO);
let want_lc = want.to_lowercase();
// One pass: find the nearest in-frame match, and keep the neighbourhood for the WARN —
// `resolve` sends the name query on a miss, so just scanning fills the cache within frames.
// `resolve` sends the name query on a miss, so scanning fills the cache within frames.
let mut found: Option<(String, f32, Vec2)> = None;
let mut nearby: Vec<(f32, String)> = Vec::new();
for (guid, tf) in &subjects {
// Only name-bearing families: a GameObject can never resolve (its name rides its own
// unmodeled query), so it would sit in the listing as noise and never match the gate.
// Only name-bearing families: a GameObject's name rides its own query, not this cache.
if !guid::is_player(guid.0)
&& !guid::is_creature_or_pet(guid.0)
&& guid::pet_number(guid.0).is_none()
File diff suppressed because it is too large Load Diff
+36 -117
View File
@@ -1,26 +1,7 @@
//! `WOW_PHASE` — **what did the renderer actually do with this batch, frame by frame?**
//!
//! Every other flicker instrument we have reads the *scene*: `benilla-visual` says which pixels would
//! not hold still, `WOW_PICK` says what geometry stands at them and whether it is visible. B38
//! exhausted that layer. There, the awning is nearer than the plank behind it, writes depth, compares
//! `GreaterEqual`, is not discarded and is not culled, and keeps the same mesh, material and texture
//! on every single frame — and on half the frames the plank behind it wins the pixel anyway
//! (decisions 0662, 0665). Under those facts that is impossible, so one of them stops being true
//! somewhere *after* the scene and before the draw, and nothing we had could see into that gap.
//!
//! So: `WOW_PHASE=<uniqueId>` watches every model batch of one placed object and reports, per frame,
//! which render phase each batch landed in and **where in the draw order** it sits. Absent from every
//! phase means it was never submitted, which no amount of looking at pixels can distinguish from
//! losing a depth test. Present, but at a different position than last frame, means the draw order
//! moved — and for two surfaces that tie, draw order *is* the result.
//!
//! `WOW_PHASE_AT=<secs>` (default 20) / `WOW_PHASE_COUNT=<n>` (default 1) shape the sampling the same
//! way the screenshot burst and the ray pick do, so a phase log and a frame stack line up frame for
//! frame and "was it drawn?" can be read against "was this frame dim or bright?".
//!
//! Only the **main** 3D view's phases are read. Shadow and prepass views bin the same entities and
//! would triple every line for nothing; a defect in *those* would show up as the wrong shadow, which
//! is a different report.
//! `WOW_PHASE=<uniqueId>`: logs, per frame, which render phase each model batch of one placed
//! object landed in and where in the draw order it sits; absent from every phase means never
//! submitted. `WOW_PHASE_AT=<secs>` (default 20) and `WOW_PHASE_COUNT=<n>` (default 1) shape the
//! sampling like the screenshot burst and the ray pick. Only the main 3D view's phases are read.
use bevy::core_pipeline::core_3d::{AlphaMask3d, Opaque3d, Transparent3d};
use bevy::prelude::*;
@@ -41,16 +22,9 @@ pub(crate) struct PhaseProbePlugin;
impl Plugin for PhaseProbePlugin {
fn build(&self, app: &mut App) {
let raw = std::env::var("WOW_PHASE").ok();
// `WOW_PHASE=particles` — the same question asked of every live PARTICLE emitter's quad
// mesh instead of one placement's batches (B16: a pool that is emitted, meshed, visible
// and textured, whose pixels never change — "was its mesh ever SUBMITTED to a phase?" is
// exactly the gap between the sim-side depth dump and the framebuffer).
// `WOW_PHASE=particles[:<bone>,…]` — the bone list is the ARMING key, not a filter: the
// watch list is collected once and then held, and a world scene is full of other emitters
// (torches, braziers) that go live long before a fixture subject spawns. Naming the bones
// the question is about (`60,61` = the voidwalker's eyes; `44,45,46` = the wisp's three
// streamers) makes the probe wait for THAT effect instead of latching onto the first live
// one it sees. Ribbon trails arm and report on the same bone key as emitters.
// `WOW_PHASE=particles[:<bone>,…]` follows every live particle emitter and ribbon trail
// instead. The bone list is an arming key, not a filter: the watch list is collected once,
// when every named bone is live, so unrelated emitters that go live first do not latch it.
let spec = raw.as_deref().map(str::trim);
let particles = spec.is_some_and(|v| v == "particles" || v.starts_with("particles:"));
let bones: Vec<u16> = spec
@@ -94,14 +68,12 @@ impl Plugin for PhaseProbePlugin {
warn!("phase: no render app — inert");
return;
};
// After `PhaseSort`: binning and sorting are both settled, so membership and order are final.
// After `PhaseSort`, so membership and order are final.
render_app.add_systems(Render, report_phases.after(RenderSystems::PhaseSort));
}
}
/// The watch list, and the sampling window. Lives in the main world; the render half reads it through
/// the extract boundary as a plain clone (it is a handful of entities once per sampled frame, so the
/// cheap thing and the correct thing are the same).
/// The watch list and the sampling window, cloned into the render world.
#[derive(Resource, Clone)]
struct PhaseWatch {
/// The placement `uniqueId` whose batches to follow.
@@ -112,11 +84,8 @@ struct PhaseWatch {
bones: Vec<u16>,
at: f32,
count: u32,
/// The watched batches: main-world entity, its WMO batch order, how it draws, and (particles
/// mode) its mesh + texture assets — so the render half can say what the GPU-side copies hold
/// at draw time.
batches: Vec<WatchedBatch>,
/// Have we found the object and started sampling?
/// Set once the object is found and sampling has started.
armed: bool,
}
@@ -127,8 +96,8 @@ impl ExtractResource for PhaseWatch {
}
}
/// One watched batch: entity, WMO batch order (or emitter bone), how it draws, and — particles
/// mode only — the mesh + texture asset ids the render half resolves to GPU-side state.
/// One watched batch: entity, WMO batch order (or emitter bone), how it draws, and in particles
/// mode the mesh and texture asset ids the render half resolves to GPU-side state.
type WatchedBatch = (
Entity,
i32,
@@ -136,15 +105,11 @@ type WatchedBatch = (
Option<(Option<AssetId<Mesh>>, AssetId<Image>)>,
);
/// Find the object's batch entities once it has streamed in. Re-run until it is found, then hold:
/// the set is per-placement and does not change, and re-collecting every frame would make the
/// watch list itself a moving part of the measurement.
/// Find the object's batch entities once it has streamed in, then hold the list.
fn collect_batches(
mut watch: ResMut<PhaseWatch>,
time: ProbeClock,
// `WorldObject` rides on **each submesh entity**, not on a parent that holds them as children —
// the same shape `WOW_PICK` relies on, where a single ray hit carries both its object identity
// and its batch. So the watch list is a filter over those entities, not a child walk.
// `WorldObject` rides on each submesh entity, not on a parent, so this filters, not walks.
parts: Query<(
Entity,
&WorldObject,
@@ -161,9 +126,8 @@ fn collect_batches(
.iter()
.filter(|(_, obj, _, _)| obj.id == want)
.map(|(e, _, part, mat)| {
// The WMO authored batch order rides in the material's `sun_scale.y` (`model_render`),
// which is also how `WOW_PICK` names a batch — so a phase line and a pick line describe
// the same surface by the same number.
// The WMO batch order rides in the material's `sun_scale.y` (`model_render`), the
// number `WOW_PICK` names a batch by.
let order = materials
.get(&mat.0)
.map_or(-1, |m| m.extension.sun_scale.y as i32);
@@ -184,27 +148,10 @@ fn collect_batches(
watch.armed = true;
}
/// The `particles` mode's collector: every LIVE **effect** mesh — particle-emitter quad clouds and
/// ribbon trails alike — labeled by its emitter bone + blend (the same identity the sim-side depth
/// dump prints, so a phase line and a dump line name the same pool) — **and every model batch in
/// the scene alongside them**.
///
/// Ribbons are here because they are the same kind of thing under the same law: a trail is one of
/// its owner model's emitters, drawn in that model's own post-batch bracket, and in our renderer it
/// is another `NoFrustumCulling` mesh in the one distance-sorted transparent list. A probe that saw
/// only quads could say "the eye glow is ordered right" while a wisp's streamers were still
/// interleaved with the wisp.
///
/// The emitters alone answer "was it submitted?". They cannot answer B16's actual question, which
/// is *relative*: a particle quad and its own model's blend batches share one `Transparent3d` list
/// sorted back-to-front, so which of the two draws last is what decides whether the eyes survive.
/// That is only readable if both sides are in the same report, on the same frame, with the sort
/// distances that produced the order — so this list holds both, and the batch column says which.
///
/// One-shot like [`collect_batches`], but gated on a live emitter rather than the clock alone: in
/// a fixture capture the subject spawns after streaming settles, so a fixed `WOW_PHASE_AT` either
/// fires before it exists or after the shot. Retrying until the pool is live pins the window to
/// the subject instead of to a wall-clock guess.
/// The `particles` mode's collector: every live particle emitter and ribbon trail, labelled by
/// emitter bone and blend, plus the model batches near them. Effects and their model's blend
/// batches share one back-to-front `Transparent3d` list, so which draws last decides what shows.
/// One-shot, armed once the named bones are live rather than on the clock alone.
fn collect_emitters(
mut watch: ResMut<PhaseWatch>,
time: ProbeClock,
@@ -232,14 +179,13 @@ fn collect_emitters(
ent,
i32::from(def.bone),
format!("EMIT {:?}(pool {})", def.blend, e.live()),
// No mesh asset since the shared effect lane (0732 P1) — the texture is the
// GPU-side link left to check; the vertices ride the lane's one buffer.
// The vertices ride the shared effect lane's one buffer; only the texture is
// a per-emitter GPU asset.
Some((None, e.texture().id())),
)
})
.collect();
// A trail with fewer than two edges builds an empty mesh, so it is submitted but draws
// nothing — the same "live" bar the emitters use (`live() > 0`).
// A trail with fewer than two edges builds an empty mesh: submitted, but it draws nothing.
found.extend(trails.iter().filter_map(|(ent, t, _)| {
let (blend, edges) = t.shape();
(edges > 0).then(|| {
@@ -254,8 +200,7 @@ fn collect_emitters(
if found.is_empty() {
return;
}
// The arming key (see the plugin's parse): every named bone must be live, or this frame is too
// early and the list would freeze around the wrong pool.
// The arming key: every named bone must be live before the list freezes.
if !watch
.bones
.iter()
@@ -263,17 +208,13 @@ fn collect_emitters(
{
return;
}
// The model side of the comparison, scoped to the batches that could actually overlap this
// pool on screen. A world scene holds tens of thousands of model batches and all of them are
// in the same sorted list; the ones that matter are the ones standing where the cloud is, so
// the scope is a radius around the watched anchors rather than "every model in the world".
// The model batches within this radius of a watched anchor, the ones that can overlap it.
const NEAR: f32 = 6.0;
let anchors: Vec<Vec3> = emitters
.iter()
.filter(|(_, e)| e.live() > 0)
.map(|(_, e)| e.anchor_world())
// A trail's entity translation IS its sort point (the sim writes the live head node
// there), so its `GlobalTransform` is the anchor without an accessor of its own.
// A trail's translation is its sort point: the sim writes the live head node there.
.chain(trails.iter().map(|(_, _, gt)| gt.translation()))
.collect();
found.extend(
@@ -311,13 +252,7 @@ fn report_phases(
}
let frame = *seen;
*seen += 1;
// The whole-frame census, before the per-batch lines. Every instrument we have is scoped to the
// thing we already suspect — this object's batches, `WorldObject`-tagged ray hits, the depth
// between the opaque and transmissive passes — so a draw that is *none of those* is invisible to
// all of them at once. An alpha-blended sprite over the awning (a particle, a glow card) would
// halve the pixel while leaving depth, material, tag, draw order and the light buffer exactly as
// measured, which is precisely the contradiction B38 now sits in. A transparent count that moves
// between a bright and a dim frame names that draw; one that does not, rules out the whole family.
// The whole-frame census first, which sees draws outside the watch list.
let transparent_total: usize = transparent.values().map(|p| p.items.len()).sum();
info!(
"phase#{frame} CENSUS opaque {} alphamask {} transparent {}",
@@ -325,9 +260,7 @@ fn report_phases(
binned_total(&alpha_mask),
transparent_total,
);
// `particles` mode: the transparent phase's TAIL — the items drawn LAST, i.e. over everything
// else. A watched mesh that is submitted, late, correct and still invisible means someone
// even later paints its pixels; this names the suspects with their sort distances.
// `particles` mode: the last 25 transparent items, drawn over everything else.
if watch.particles {
if let Some(phase) = transparent.values().max_by_key(|p| p.items.len()) {
let n = phase.items.len();
@@ -340,10 +273,8 @@ fn report_phases(
}
}
for &(entity, submesh, ref blend, assets) in &watch.batches {
// What the GPU-side copies hold at draw time — the last links between "submitted to a
// phase" and "fragments exist": a phase item whose RenderMesh is absent draws nothing,
// and one whose material TEXTURE never became a GpuImage has no material bind group, so
// the draw command chain aborts silently every frame.
// What the GPU-side copies hold: an item with no `RenderMesh`, or whose texture never
// became a `GpuImage` (no material bind group), silently draws nothing.
let gpu = assets.map(|(mesh_id, tex_id)| {
let mesh = match mesh_id {
// The shared effect lane: no per-emitter mesh asset exists to check.
@@ -361,9 +292,7 @@ fn report_phases(
});
let main = MainEntity::from(entity);
let mut found = Vec::new();
// The main view is whichever phase map holds the most entities: shadow and prepass views bin
// subsets, so the largest is the one that draws the frame. Picking by view entity would tie
// this to the camera's identity in the render world, which is not stable to read from here.
// The main view is the phase map holding the most entities; shadow and prepass bin subsets.
if let Some(pos) = binned_position::<Opaque3d>(&opaque, main) {
found.push(format!("Opaque3d @{pos}"));
}
@@ -372,22 +301,15 @@ fn report_phases(
}
for phase in transparent.values() {
if let Some(pos) = phase.items.iter().position(|item| item.entity.1 == main) {
// The sort key itself, not just the slot: `Transparent3d` orders on view-space z +
// the material's depth bias, ascending = farthest first (`benilla_world::sky_order`). Two
// slots tell you WHICH drew last; the two distances tell you WHY, and whether the
// gap is a real spatial one or a tie the sort broke arbitrarily.
// The sort key too: view-space z plus the material's depth bias, ascending =
// farthest first (`benilla_world::sky_order`).
found.push(format!(
"Transparent3d @{pos} d {:.3}",
phase.items[pos].distance
));
}
}
// "NOT SUBMITTED" is the whole point of the instrument: a batch that reached no phase was
// never drawn, and that is indistinguishable in the pixels from one that drew and lost.
// The entity, because **batch order does not identify a surface**: a WMO placement is many
// groups and the order is per-group, so one placement has several batches sharing an order
// (79 entities over 19 orders on the Far Watch Post tower). Keying a report by order alone
// silently merges them and can hide the one batch that moved.
// Keyed by entity: batch order is per WMO group, so one placement repeats orders.
info!(
"phase#{frame} {entity} batch order {submesh:3} {blend:10} {} -> {}",
gpu.as_deref().unwrap_or(""),
@@ -400,8 +322,7 @@ fn report_phases(
}
}
/// The entity's ordinal within the largest binned phase for `BPI`, walking bins in iteration order —
/// which is the order the pass draws them, so the ordinal is a draw position and not just a yes/no.
/// The entity's draw position within the largest binned phase for `BPI`, in bin iteration order.
fn binned_position<BPI>(phases: &ViewBinnedRenderPhases<BPI>, main: MainEntity) -> Option<usize>
where
BPI: bevy::render::render_phase::BinnedPhaseItem,
@@ -434,9 +355,7 @@ where
None
}
/// How many entities the main binned phase for `BPI` holds this frame — the census counterpart of
/// [`binned_position`], picking the same "largest phase map is the main view" way so the two numbers
/// describe the same view.
/// How many entities the largest binned phase for `BPI` holds this frame.
fn binned_total<BPI>(phases: &ViewBinnedRenderPhases<BPI>) -> usize
where
BPI: bevy::render::render_phase::BinnedPhaseItem,
+37 -119
View File
@@ -1,40 +1,10 @@
//! `WOW_PICK` — the headless **"what is at this pixel, all the way back"** probe.
//!
//! The flicker instruments (decisions 0653/0656) localise a defect: `benilla-visual hotspot` hands
//! back a pixel box and says *this* is what would not hold still. Naming what is actually there was
//! then a manual step — read the ADT placements, guess which model the report meant, hope. The
//! interactive inspector ([`benilla_world::interact`]) answers it with a cursor, which an unattended probe
//! does not have.
//!
//! So: `WOW_PICK="<x>,<y>[;<x>,<y>…]"` (+ `WOW_PICK_AT=<secs>`, default 20) casts a ray through each
//! pixel and logs **every** hit along it, nearest first — not just the front one.
//!
//! `WOW_PICK_COUNT=<n>` / `WOW_PICK_EVERY=<secs>` (0 = one per frame) repeat the cast, the same shape
//! as the screenshot burst — because the cast honours `Visibility`, a hit that *vanishes* between
//! adjacent casts is a surface being **culled**, not one losing a depth test. That distinction is
//! invisible to a single cast and to the pixels alike: both look like the surface went away.
//!
//! Reporting the whole ray is the point. A surface that swaps with another between frames has a
//! rival *behind* it, and the gap between hit 0 and hit 1 is the number that decides the diagnosis:
//! an exact tie is a coplanar authoring tie (no depth precision can break it — only a deterministic
//! order or a bias), while a few millimetres is a precision question. The nearest hit alone cannot
//! tell those apart, and which one it is decides the fix.
//!
//! **The gap that decides it is the PERPENDICULAR one, not the distance along the ray** — reading
//! the along-ray gap as the separation is how B38 was mis-diagnosed twice. Where the ray meets a
//! surface obliquely it travels a long way between two planes that are barely apart; at the awning
//! there, hit 0 and hit 1 are 1–2 yd apart *along the ray* and 3–45 cm apart perpendicular. Both
//! are reported, along with each hit's **incidence angle**, because a surface nearly edge-on to the
//! ray is lost or won by sub-pixel coverage rather than by depth, and that is a different defect
//! wearing the same appearance.
//!
//! Each hit also names its **material and bound texture**, so "the same batch drew something else
//! this frame" is visible at all — it is indistinguishable from a depth fight in the pixels, and
//! ruling it out is what leaves the depth story standing.
//!
//! Coordinates are **screenshot pixels** — the same space `benilla-visual` reports boxes in — so a
//! hotspot box can be pasted straight in. They are divided by the window's scale factor here, since
//! `viewport_to_world` works in logical units and a Retina capture is 2× the logical window.
//! `WOW_PICK="<x>,<y>[;<x>,<y>…]"`: the headless inspector. Casts a ray through each screenshot
//! pixel after `WOW_PICK_AT=<secs>` (default 20) and logs every hit along it, nearest first, with
//! the along-ray and perpendicular gap to the previous hit, the incidence angle, the material,
//! bound texture and shading inputs. `WOW_PICK_COUNT=<n>` and `WOW_PICK_EVERY=<secs>` (0 = one
//! per frame) repeat the cast; the cast honours `Visibility`, so a hit that vanishes between casts
//! was culled. Pixels are in `benilla-visual`'s space and divided by the window scale factor, since
//! `viewport_to_world` works in logical units.
use bevy::mesh::MeshTag;
use bevy::platform::collections::HashSet;
@@ -103,36 +73,26 @@ fn parse_pixels(spec: &str) -> Vec<Vec2> {
.collect()
}
/// The filter deciding what a cast may hit — see [`fire_pick`]'s `objects` for why it is both.
/// The filter deciding what a cast may hit; see [`fire_pick`]'s `objects` for why it is both.
type Pickable = Or<(
With<WorldObject>,
With<benilla_world::model_render::ModelPart>,
)>;
/// What a hit entity is asked for: its identity, the batch class it draws as, the material
/// carrying the WMO batch order, and its per-instance `MeshTag` (`Option` because a hit need not be a
/// model batch at all — and an equipped item's batch carries no [`WorldObject`]).
/// What a hit entity is asked for: identity, batch class, the material carrying the WMO batch
/// order, the per-instance `MeshTag`, and whether it is a camera-facing card. An equipped item's
/// batch carries no [`WorldObject`].
type HitIdentity = (
Option<&'static WorldObject>,
Option<&'static benilla_world::model_render::ModelPart>,
Option<&'static MeshMaterial3d<WowModelMaterial>>,
Option<&'static MeshTag>,
// Is this hit a camera-FACING batch (decision 0153's world-root card) or the model's own
// geometry? The two are indistinguishable in the pixels and lead to completely different
// diagnoses — a card that should be culled vs a submesh drawn with the wrong texture — so the
// cast says which. (`card` in the line below.)
Has<benilla_world::billboard::BillboardCard>,
);
/// Every shading input the batch actually has, as text — the five packed uniform rows and the base
/// colour, printed per cast so a value that MOVES inside one material is visible at all.
///
/// This exists because comparing material **handles** across frames — the check that "eliminated" a
/// distance-fade material swap for B38 (0665) — is blind to exactly this: the handle is stable while
/// the contents move, and three of these are re-sampled per frame *by design* (`sun_scale.zw`'s UV
/// animation, the tint lane's per-instance clone, the fade twin's alpha). `tint.w` is the WMO
/// interior batch-class lane (0 exterior / 1 interior-unlit / 2 the MOCV lerp) — a term that
/// re-lights every surface of one building together, which is B38's whole footprint.
/// Every shading input the batch has, as text: the five packed uniform rows and the base colour,
/// per cast, since several move inside one material handle. `tint.w` is the WMO batch-class lane
/// (0 exterior, 1 interior-unlit, 2 the MOCV lerp).
fn shading_of(mat: Option<&WowModelMaterial>) -> String {
let Some(m) = mat else {
return "<no WowModelMaterial>".to_string();
@@ -144,12 +104,8 @@ fn shading_of(mat: Option<&WowModelMaterial>) -> String {
sidn {:.3},{:.3},{:.3} win {:.0} sunsel {:.2} bias {:.0} uv {:.4},{:.4} \
clutter {:.1},{:.1},{:.1},{:.1} base {:.3},{:.3},{:.3},{:.3} {:?}",
e.model_flags.x,
// `int` is `model_flags.z` — the LIGHT-LANE flag, and it was the one row of the packed
// uniforms this line did not print. Without `is_wmo` it selects the interior-prop lane,
// i.e. "this batch is lit by the SH probe its MeshTag names" rather than by the sky; with
// it, the WMO interior batch-class lanes `class` then splits. A hit that reads
// `wmo 0 int 1` is asking about a probe slot, and the `slot` reading of the tag beside it
// is the one that matters — which is the whole diagnosis of B373.
// `int`, the light-lane flag: without `wmo` it lights the batch from the SH probe slot its
// `MeshTag` names; with it, `class` picks the WMO interior lane.
e.model_flags.z,
e.model_flags.y,
e.tint.w,
@@ -176,8 +132,7 @@ fn shading_of(mat: Option<&WowModelMaterial>) -> String {
)
}
/// Everything needed to turn a hit entity into a line of text — bundled so the system keeps one
/// parameter for "describe this hit" rather than two that must always travel together.
/// Everything needed to turn a hit entity into a line of text.
#[derive(bevy::ecs::system::SystemParam)]
struct HitNames<'w, 's> {
identity: Query<'w, 's, HitIdentity>,
@@ -189,19 +144,11 @@ fn fire_pick(
time: ProbeClock,
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
// What the ray may hit. `WorldObject` is the world's identity tag (doodads, WMOs, unit BODY
// parts) — but an EQUIPPED item carries none: the item lane spawns its parts and cards without
// one, because that component also feeds the mouseover/targeting fallback and a weapon is not a
// target. So the probe would answer "what is at this pixel" for a character's skin and silently
// skip its helm, its pauldrons, its weapon and every camera-facing card hanging off them —
// exactly the geometry a "this bit of my gear looks wrong" report is about (found chasing the
// Field Marshal pauldron, decision 0836). `ModelPart` rides every drawn batch in the client, so
// the probe keys on it too and stays a pure instrument: nothing outside this file reads it.
// What the ray may hit. Equipped items carry no `WorldObject` (it feeds targeting, and a
// weapon is not a target), so `ModelPart`, on every drawn batch, is accepted too.
objects: Query<Entity, Pickable>,
names: HitNames,
// Everything drawn, entity-owned or not ([`WorldPick`]). Without the entity-less lanes this
// probe reports `0 hits` over every absorbed tree, building and prop — which is exactly what
// it did between 1434's default flip and 1534.
// Everything drawn, entity-owned or not, including the consolidated lanes ([`WorldPick`]).
pick: WorldPick,
) {
if probe.taken >= probe.count || probe.pixels.is_empty() || time.elapsed_secs() < probe.next_at
@@ -209,7 +156,7 @@ fn fire_pick(
return;
}
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
return; // no world camera / window yet — try again next frame
return; // no world camera or window yet
};
let cast = probe.taken;
probe.taken += 1;
@@ -222,18 +169,10 @@ fn fire_pick(
warn!("pick ({}, {}): outside the viewport", pixel.x, pixel.y);
continue;
};
// `all_hits` is the whole reason this exists: the nearest hit alone cannot name the rival
// surface behind it, which is exactly what we came for. The cast reads each part's
// RESIDENT geometry (decision 0857) — the render meshes are `RENDER_WORLD`-only since
// 0834, so a `MeshRayCast` here reports nothing for any static model.
// Every hit, not the nearest. The cast reads each part's resident geometry: render meshes
// are `RENDER_WORLD`-only, so a `MeshRayCast` would find no static model.
let hits = pick.cast(ray, &pickable, true);
// The camera's GLOBAL transform, bit-exact, on the line that carries the cast index — so
// "did the camera move on this frame?" is answerable against "was this frame dim?" without
// aligning two logs by hand. It is deliberately *this* transform: it is the one
// `viewport_to_world` just used to build the ray, so a converging hit distance can be
// attributed to the camera or to the geometry rather than left ambiguous. A dump inside
// `seat_camera` cannot do that job — it prints the local `Transform` before propagation, and
// it has no frame index, which is how it got read against the wrong 75 frames once already.
// The camera's global transform, bit-exact: the one `viewport_to_world` built the ray from.
let eye = cam_tf.translation();
info!(
"pick#{cast} ({}, {}) [logical {:.1}, {:.1}]: {} hits eye [{:08x},{:08x},{:08x}] {:.6?}",
@@ -247,12 +186,8 @@ fn fire_pick(
eye.z.to_bits(),
eye,
);
// Distance ALONG THE RAY is not the gap that decides a depth fight, and reading it as if it
// were is how B38 got called "not a depth fight" (0662). At a grazing angle the ray travels
// nearly parallel to both surfaces, so a yard of ray can separate two planes that are a hair
// apart — and near a line where two planes INTERSECT the perpendicular gap goes to zero
// while the along-ray gap stays large. So report the perpendicular distance from this hit's
// point to the previous hit's plane as well: that is the number a depth buffer sees.
// The perpendicular distance to the previous hit's plane is the gap a depth buffer sees;
// at a grazing angle the along-ray gap is far larger.
let mut previous: Option<(f32, Vec3, Vec3)> = None;
for (i, found) in hits.iter().enumerate() {
let hit = &found.hit;
@@ -265,11 +200,8 @@ fn fire_pick(
)
});
previous = Some((hit.distance, hit.point, hit.normal));
// A CONSOLIDATED hit — a merge blob's member or a retained-pass item — has no entity,
// so the per-entity columns (material, bound texture, mesh tag, blend) are simply not
// there to read: its draw state lives in the lane's baked record, not in a component.
// It is still named, posed and ordered like every other hit, which is what a "what is
// at this pixel" reading is for; `CONSOLIDATED` says why the columns are bare.
// A consolidated hit (a merge blob member or retained-pass item) has no entity, so its
// per-entity columns are bare; its draw state lives in the lane's baked record.
let (part, mat, tag, card) = match found.entity.map(|e| names.identity.get(e)) {
Some(Ok((_, part, mat, tag, card))) => (part, mat, tag, card),
Some(Err(_)) => {
@@ -284,32 +216,21 @@ fn fire_pick(
" CONSOLIDATED"
};
let obj = found.object.as_ref();
// The WMO authored batch order rides in the material's `sun_scale.y` (see
// `model_render`): 0 means "no bias applied", which for a WMO batch is itself a finding.
// The WMO batch order rides in the material's `sun_scale.y` (`model_render`); 0 means
// no bias applied.
let resolved = mat.and_then(|m| names.materials.get(&m.0));
let batch = resolved
.map(|m| m.extension.sun_scale.y as i32)
.unwrap_or(-1);
// The material + texture the hit actually draws with, per cast. A surface that swaps
// appearance while its GEOMETRY stays put (identical hits, identical order) is either
// being re-lit or bound to something else; only naming the bound texture each frame
// tells those apart, and "the same batch drew a different texture this frame" is
// otherwise completely invisible from the pixels.
// The texture the hit is bound to this cast.
let tex = resolved
.and_then(|m| m.base.base_color_texture.as_ref())
.map_or("-".to_string(), |h| format!("{:?}", h.id()));
// How edge-on is this surface to the ray? 90° is face-on; near 0° the triangle is nearly
// parallel to the view and covers a pixel by a hair, so which of it and whatever lies
// behind wins a pixel is decided by sub-pixel coverage rather than by depth — a
// completely different defect from a depth fight, and indistinguishable in the pixels.
// The angle to the ray: 90° is face-on; near 0° sub-pixel coverage, not depth, decides.
let incidence = ray.direction.dot(hit.normal).abs().clamp(0.0, 1.0).asin();
// An untagged batch — an equipped item's part or its billboard card — has no world
// identity of its own; it is named by its blend + material + texture, which is what
// separates "the pauldron's plate" from "the pauldron's camera-facing trim".
// `detail` is the inspector's second line — the kind-specific facts the model path can't
// carry (a WMO prop's lighting lane, an emitter count). Printed here because this probe
// IS the inspector without a cursor, and a prop that draws wrong usually differs from a
// right one only in that line (decision 0969: the black Booty Bay arch).
// An equipped item's batch has no world identity and is named by blend, material and
// texture. `detail` is the inspector's second line (a prop's lighting lane, an emitter
// count).
let (kind, id, label) = obj.map_or_else(
|| ("<worn>".to_string(), String::new(), String::new()),
|o| {
@@ -332,11 +253,8 @@ fn fire_pick(
incidence.to_degrees(),
mat.map(|m| m.0.id()),
);
// The contents, not the identity — the whole point (see [`shading_of`]). Printed for
// every hit on every cast: with `WOW_PICK_EVERY=0` that is a per-frame record of every
// shading input this batch has, which is the only way a term moving *inside* one
// material is visible. The per-instance tag rides the same line because bits 16..=29
// mean different things under the two material laws, so the pair must be read together.
// The material's contents, every hit every cast. The tag shares the line because its
// bits 16..=29 mean different things under the two material laws.
info!(
" {} tag {}",
shading_of(resolved),
+95 -232
View File
@@ -1,66 +1,23 @@
//! The auction-arc live probe (`WOW_PROBE_AUCTION=1`) — decision 1511's end-to-end instrument:
//! GM-hop to a real Stormwind auctioneer, greet it on the wire, and drive browse / throttle /
//! sell / owner-list / cancel through the **live Lua VM** exactly as a click would, printing a
//! `PROBE_AUCTION:` trace line with a PASS/FAIL/SKIP verdict per step and a final
//! `PROBE_AUCTION: DONE pass=<n> fail=<m>` summary. Modeled closely on [`super::probe_mail`]
//! (same phase-machine shape, same trace style, same self-terminating exit).
//! The auction live probe (`WOW_PROBE_AUCTION=1`): hops to a Stormwind auctioneer, greets it on
//! the wire and drives browse, throttle, sell, owner list and cancel through the live Lua VM as a
//! click would, logging one `PROBE_AUCTION:` PASS/FAIL/SKIP line per step, then
//! `PROBE_AUCTION: DONE pass=<n> fail=<m>`, and exits. Non-combat; the switches are
//! `docs/CONTRIBUTING.md`, "Running it unattended".
//!
//! ## The one assertion no unit test can make
//! The window opens on the server's `MSG_AUCTION_HELLO` reply, not on the click, and the reply's
//! house id (checked in `1..=7`) keys the deposit rate the sell pane quotes.
//!
//! Step (2). **The window is opened by the server, not by the click** ([`crate::ui_auction`]):
//! `MSG_AUCTION_HELLO` goes out and nothing happens; the *reply* — the same opcode coming back
//! with the auctioneer guid and an `AuctionHouse.dbc` house id — opens the session and fires
//! `AUCTION_HOUSE_SHOW`. Every other piece of this arc is downstream of that reply arriving, so it
//! 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.
//! Server behaviour the phases follow (vmangos `AuctionHouseHandler.cpp`):
//! - One list request at a time: a second while one is in flight is dropped silently (`:710`).
//! - `etime` is minutes, one of 1, 4 or 12 times the 2 h `MIN_AUCTION_TIME` (`:287-291`).
//! - The deposit is `SellPrice × count × (etime / MIN_AUCTION_TIME) × depositPercent / 100`
//! (`AuctionHouseMgr.cpp:98`); at 120 minutes the client's `CalculateAuctionDeposit` agrees
//! with it to the copper.
//! - A cancel with no bidder is free and returns the item by mail.
//! - A GM account is refused with `RESTRICTED_ACCOUNT` unless `GM.AllowTrades` is on (`:249`).
//!
//! ## 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 —
//! bit 12 only (`UNIT_NPC_FLAG_AUCTIONEER`), no gossip bit — so the right-click route sends
//! `AuctionHello` directly with no gossip pre-empt (`target::click::interact_command`'s own note
//! on the service ladder), which is precisely the command this probe sends.
//!
//! ## What the server does that shapes the phases (VERIFIED, vmangos `AuctionHouseHandler.cpp`)
//!
//! - **One AH list request at a time.** `HandleAuctionListItems`/`ListOwnerItems`/`ListBidderItems`
//! all open with `if (ReceivedAHListRequest()) return;` — a second query while one is in flight
//! is dropped *silently*. Every list phase here therefore waits for its answer before asking
//! again, and the owner phase re-asks on a timer rather than spamming.
//! - **`etime` is minutes on the wire**, converted server-side (`etime * MINUTE`) and switched
//! against 1/4/12 × `MIN_AUCTION_TIME` (2 h). 120 is the shortest legal listing.
//! - **The deposit** is `uint32(SellPrice × count × (etime / MIN_AUCTION_TIME) × depositPercent /
//! 100)` (`AuctionHouseMgr::GetAuctionDeposit`), with `Auction.Deposit.Min = 0` and
//! `Rate.Auction.Deposit = 1` in this deploy's `mangosd.conf`. At **120 minutes the unit count
//! is 1**, which is exactly where the client's own arithmetic (`CalculateAuctionDeposit`, whose
//! intermediate truncation disagrees with the server on longer listings — decision 1511 §7) and
//! the server's agree to the copper. That is why step (5) lists for 120 minutes: it makes "money
//! fell by exactly the quoted deposit" a real assertion instead of a flaky one.
//! - **A cancel with no bidder is free** and returns the item **by mail**, not to the bag
//! (`HandleAuctionRemoveItem`) — so a clean run leaves the auction house as it found it and one
//! letter in the probe's own mailbox. Vanilla's behaviour, not litter we could avoid.
//! - `GM.AllowTrades = 1` in this deploy, so the gmlevel-6 probe accounts are not refused with
//! `AUCTION_ERR_RESTRICTED_ACCOUNT` (the guard at the top of `HandleAuctionSellItem`).
//!
//! ## The row-identity rule this probe obeys
//!
//! [`super::probe_mail`]'s header records the defect worth not repeating: a probe that re-finds
//! its row **by predicate** each tick reads its own success as absence, because a successful
//! action is exactly what stops the row matching. So the auction created in step (5) is
//! remembered by the **auction id the server's own `STARTED` result carried**, once, and every
//! later step tracks that id — never "the row whose item is Linen Cloth".
//!
//! ## The run recipe
//!
//! ```text
//! WOW_PROBE_AUCTION=1 WOW_NOSOUND=1 WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive \
//! cargo run -p benilla
//! ```
//! (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.
//! The auction is tracked by the id the server's `STARTED` result carried, never re-found by a
//! predicate over the rows, since a successful action is what stops a row matching.
use bevy::prelude::*;
@@ -73,37 +30,25 @@ use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectSt
use crate::player::Player;
use crate::ui_auction::AuctionOpen;
/// Auctioneer Fitch's spawn (vmangos `creature` guid 12696, entry 8719, map 0) — the `.go xyz`
/// target; the auctioneer itself is then scanned out of the streamed world by its npc flag, never
/// by a hardcoded guid (the taxi/bank probes' idiom).
/// Auctioneer Fitch's spawn (vmangos `creature` guid 12696, map 0), the `.go xyz` target; the
/// unit itself is found by its npc flag.
const AUCTIONEER_AT: [f32; 3] = [-8821.53, 659.886, 97.4645];
/// Her creature template entry — reported, not required (the flag below is the identity test).
/// Her creature template entry, reported only; her `npc_flags` is 4096, auctioneer only, so the
/// right-click route sends `AuctionHello` with no gossip in between.
const AUCTIONEER_ENTRY: u32 = 8719;
/// `UNIT_NPC_FLAG_AUCTIONEER` (bit 12) — the same bit the cursor classifier and the click router
/// key on (`target::cursor_mode::npc_flags::AUCTIONEER`).
/// `UNIT_NPC_FLAG_AUCTIONEER` (bit 12).
const NPC_FLAG_AUCTIONEER: u32 = 0x1000;
/// How wide the streamed world is searched for an auctioneer. Generous, so a slightly-off `.go`
/// landing still finds one — but the NEAREST match is the one greeted, never the first the ECS
/// happens to yield.
///
/// **That distinction cost this probe its first run.** Stormwind's Trade District holds several
/// auctioneers a few yards apart; a plain `find` picked one 7.0 yd away and the greeting came back
/// as *nothing at all*, because the server's `CanInteractWithNPC` ends in
/// `IsWithinDistInMap(this, INTERACTION_DISTANCE)` and refuses silently — the exact failure mode
/// step (2) exists to detect, arrived at by our own aim rather than by a defect.
/// Wider than the interact range, so a slightly-off `.go` landing still finds one; the nearest is
/// greeted, as several auctioneers stand a few yards apart.
const SCAN_RANGE: f32 = 12.0;
/// vmangos `INTERACTION_DISTANCE` (`ObjectDefines.h:24`) — 5.0 yd between centres, plus each
/// side's bounding radius. Anything past it is refused with no packet, so the probe would rather
/// say so than send a greeting it knows will be ignored.
/// vmangos `INTERACTION_DISTANCE` (`ObjectDefines.h:24`); past it a greeting gets no reply.
const INTERACT_MAX_YD: f32 = 5.0;
/// The fixture listed in step (5): Linen Cloth — cheap, stackable, no durability/bind/equip
/// complications, and a `sell_price` of 13 c (live-DB verified), so a stack of five has a
/// nonzero deposit. A ZERO deposit would make the money assertion vacuous.
/// The step (5) fixture: Linen Cloth, whose 13 c sell price gives a stack of five a nonzero
/// deposit.
const ITEM_ENTRY: u32 = 2589;
const ITEM_COUNT: u32 = 5;
/// The listing length. 120 minutes is the one duration where the client's deposit arithmetic and
/// the server's agree exactly (module doc).
/// 120 minutes, where the client's deposit quote and the server's charge agree exactly.
const DURATION_MINUTES: u32 = auction_duration::SHORT_MINUTES;
const SETTLE_SECS: f64 = 3.0;
@@ -112,22 +57,17 @@ const HELLO_TIMEOUT_SECS: f64 = 10.0;
const LIST_TIMEOUT_SECS: f64 = 15.0;
const ACTION_TIMEOUT_SECS: f64 = 10.0;
const ITEM_TIMEOUT_SECS: f64 = 10.0;
/// How long the refusal is watched before it is believed — the throttle drops the query with no
/// event at all, so the only honest reading is "nothing went out, and nothing came back, for this
/// long" (`AuctionWireLog`'s own reason for existing).
/// How long nothing must go out or come back before the throttle's silent refusal is believed.
const REFUSAL_WATCH_SECS: f64 = 2.0;
/// The client throttle is 5 s; give the recovery a generous but bounded window.
/// The client throttle is 5 s.
const RECOVER_TIMEOUT_SECS: f64 = 12.0;
/// The owner list is re-asked on this cadence (the server drops a second in-flight list request
/// silently, so a re-ask has to be spaced, not spammed).
/// The owner list re-ask cadence, spaced because the server drops an in-flight second request.
const OWNER_REASK_SECS: f64 = 3.0;
const OWNER_TIMEOUT_SECS: f64 = 20.0;
/// How long a UI EVENT is given to trail the state change it answers. Nothing orders this probe
/// against `feed_auction`, which is what fires them, so an event can legitimately land a frame
/// after the wire state it reports — but not ninety of them.
/// How long a UI event may trail its wire state: `feed_auction` fires it, unordered with this
/// probe.
const EVENT_GRACE_SECS: f64 = 1.5;
/// How long the sell slot is given to empty itself after the listing is away, before (5c) calls
/// it. Generous: it is a one-line clear, not a round trip.
/// How long the sell slot has to empty after the listing is away.
const SELL_SLOT_GRACE_SECS: f64 = 3.0;
pub(crate) struct ProbeAuctionPlugin;
@@ -139,61 +79,45 @@ impl Plugin for ProbeAuctionPlugin {
}
}
/// The probe's phase machine + the identities it discovers along the way (kept resource-level, not
/// per-variant, since several later phases re-resolve the same auction by its stable id — the
/// module doc's row-identity rule).
/// The probe's phase machine and the identities it discovers along the way.
#[derive(Resource, Default)]
struct AuctionProbe {
phase: Phase,
/// The auctioneer's guid, once streamed in.
auctioneer: Option<u64>,
/// **The** handle: the auction id the server's own `STARTED` result carried. Everything after
/// step (5) tracks this, never a predicate over the rows.
/// The auction id the server's `STARTED` result carried; every later step tracks it.
auction_id: Option<u32>,
/// The vendor value of the stack in the sell slot — what both deposits are computed from.
/// The sell-slot stack's vendor value, which both deposits are computed from.
stack_value: i64,
/// `CalculateAuctionDeposit(120)` — the client's own quote, which step (5) then holds the
/// server's charge against.
/// The client's `CalculateAuctionDeposit` quote, held against the server's charge.
deposit: i64,
min_bid: i64,
buyout: i64,
/// The purse immediately before `StartAuction` went in.
/// The purse just before `StartAuction`.
baseline_money: u32,
/// `AUCTION_OWNED_LIST_UPDATE`'s count immediately before `StartAuction` went in.
///
/// 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.)
/// `AUCTION_OWNED_LIST_UPDATE`'s count just before `StartAuction`, so an owner re-query the
/// `STARTED` result queues cannot fire the event before the baseline is read.
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 `UI_ERROR_MESSAGE` lines raised before the window opened: on a probe account the GM
/// and GOD mode banners, not the arc's.
red_baseline: i64,
passes: u32,
fails: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
/// Latched once [`Phase::Done`] has fired its exit.
exited: bool,
}
/// Every field is `Copy` — the phase is snapshotted out of the resource each tick
/// ([`auction_probe`]'s `let phase = probe.phase;`), which frees the match arms to mutate `probe`
/// without fighting the borrow checker over `probe.phase`; an arm that wants to "keep waiting"
/// simply never writes it.
/// `Copy`, so each tick snapshots it and the arms can mutate `probe`; an arm that keeps waiting
/// never writes it.
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Wait,
/// `.go` + `.additem` issued; settling before the world streams the auctioneer in (step 1).
/// `.go` sent; settling while the world streams the auctioneer in (step 1).
Settling {
sent_at: f64,
},
/// `AuctionHello` sent; waiting for the **reply** to open the session (step 2).
/// `AuctionHello` sent; waiting for the reply to open the session (step 2).
Greet {
sent_at: f64,
show_baseline: i64,
@@ -210,15 +134,15 @@ enum Phase {
since: f64,
event_baseline: i64,
},
/// The second query, which the client throttle must refuse outright (step 4a).
/// A second query, which the client throttle must refuse (step 4a).
Refuse {
since: f64,
sent: bool,
sent_baseline: u32,
results_baseline: u32,
},
/// Waiting for `CanSendAuctionQuery()` to come back, then proving it with a real third query
/// that actually goes out (step 4b).
/// Waiting for `CanSendAuctionQuery()` to return true, then a third query that must go out
/// (step 4b).
Recover {
since: f64,
sent: bool,
@@ -229,8 +153,7 @@ enum Phase {
since: f64,
sent: bool,
},
/// Picked up and dropped in the sell slot; waiting for the slot to read back a priced item
/// (step 5 prep).
/// Dropped in the sell slot; waiting for it to read back a priced item (step 5 prep).
Attach {
since: f64,
},
@@ -238,9 +161,7 @@ enum Phase {
Sell {
since: f64,
},
/// The money assertion (which trails the verdict by however long the descriptor takes) and
/// the sell-slot one. Each is LATCHED, because they land at different moments and neither may
/// re-print while the other is still waiting.
/// The money and sell-slot assertions, each latched since they land at different moments.
SellMoney {
since: f64,
money_done: bool,
@@ -268,8 +189,7 @@ enum Phase {
Done,
}
/// One Lua-side event counter (`ProbeAuctionEvents`), or `0` on any eval hiccup — treated as
/// "nothing observed yet", never a panic.
/// One Lua-side event counter (`ProbeAuctionEvents`), or 0 on an eval error.
fn event_count(script: &UiScript, event: &str) -> i64 {
script
.eval::<i64>(&format!(
@@ -278,24 +198,13 @@ fn event_count(script: &UiScript, event: &str) -> i64 {
.unwrap_or(0)
}
/// The newest `UI_ERROR_MESSAGE` text the hook logged (the arc's failure channel — a refused
/// auction command surfaces here and nowhere else).
/// The newest `UI_ERROR_MESSAGE` text the hook logged, where a refused auction command surfaces.
fn last_error(script: &UiScript) -> String {
script
.eval::<String>("return ProbeAuctionErrors[table.getn(ProbeAuctionErrors)] or \"\"")
.unwrap_or_default()
}
/// (6d) Where the STARTED verdict was SAID — the live half of the director's 2026-08-22 report that
/// an auction outcome arrived as a red centre-screen line.
///
/// Two halves, and the second is the one that would have caught the bug: the "Auction created." line
/// has to be in the **chat** log (catalog row `0x178`, kind 0 → `CHAT_MSG_SYSTEM`), and a clean run
/// has to have raised **no red line at all**. Asserting only the first would still pass if we
/// printed it in both places.
///
/// 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
@@ -303,6 +212,9 @@ fn red_count(script: &UiScript) -> i64 {
.unwrap_or(-1)
}
/// (6d) The `STARTED` verdict prints as a chat line (catalog row `0x178`, kind 0,
/// `CHAT_MSG_SYSTEM`) and never as a red line; matched against the player's own
/// `ERR_AUCTION_STARTED`.
fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
let said = script
.eval::<i64>(
@@ -331,8 +243,7 @@ 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.
// Listed from the baseline on: the login banners are not this arc's output.
let lines = script
.eval::<String>(&format!(
"local t = {{}} \
@@ -351,23 +262,10 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
}
}
/// (6c) The owner row's money frame, read out of the live VM — the live half of the director's
/// 2026-08-22 report that the price columns were dropping their zeros.
///
/// The law under test is `MoneyTypeInfo["AUCTION"]`'s `showSmallerCoins`: it collapses only the
/// **leading** zero denominations, so a 1-silver minimum bid reads `1s 0c` and not a lone silver
/// 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 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.
/// (6c) The owner row's money frame: `MoneyTypeInfo["AUCTION"]`'s `showSmallerCoins` hides only
/// leading zero denominations, so a 1-silver bid reads `1s 0c` (`MoneyFrame.lua:223-253`). The
/// Auctions tab must be in front: its `OnShow` sets the `page` that `AuctionFrameAuctions_Update`
/// paints the row with.
fn owner_money_check(script: &UiScript, probe: &mut AuctionProbe) {
let (gold, silver, copper) = (
probe.min_bid / 10_000,
@@ -415,10 +313,8 @@ fn owner_money_check(script: &UiScript, probe: &mut AuctionProbe) {
}
}
/// The 1-based **display** index our auction currently sits at in the owner list, or `None` if it
/// is not on the page. Read off the app-side page rather than by predicate: `CancelAuction(index)`
/// is mapped back through the same sorted view the feed pushed, and with no header ever clicked
/// that view is the wire order (`ui_auction::sort`: an empty stack leaves the order alone).
/// The 1-based display index of our auction in the owner list. `CancelAuction(index)` maps back
/// through the same sorted view, which is wire order while no header is clicked.
fn owner_index_of(auction: &AuctionOpen, auction_id: u32) -> Option<u32> {
auction.lists[OWNER]
.entries
@@ -427,7 +323,7 @@ fn owner_index_of(auction: &AuctionOpen, auction_id: u32) -> Option<u32> {
.map(|i| i as u32 + 1)
}
/// Step (5)'s entry — the next phase every step-4 exit, pass or fail, funnels into.
/// Step (5)'s entry, where every step-4 exit lands.
fn ensure_item(now: f64) -> Phase {
Phase::EnsureItem {
since: now,
@@ -435,8 +331,7 @@ fn ensure_item(now: f64) -> Phase {
}
}
/// Step (7)'s entry. Reached from step (6) either way: a listing we created is cancelled even
/// when the assertion about it failed, because leaving it is the one thing this probe must not do.
/// Step (7)'s entry, reached from step (6) pass or fail so a created listing is never left.
fn cancel_at(now: f64) -> Phase {
Phase::Cancel {
since: now,
@@ -487,18 +382,15 @@ fn auction_probe(
return; // not in-world yet
};
let Some(script) = script else {
return; // no UI VM this build (headless net-only) — nothing this probe can drive
return; // no UI VM in a headless build
};
let now = time.elapsed_secs_f64();
// A cheap `Copy` snapshot (see [`Phase`]'s doc).
let phase = probe.phase;
match phase {
Phase::Wait => {
// The observation channel: one hidden frame counting every event this arc fires, plus
// a log of the `UI_ERROR_MESSAGE` texts (the mail probe's `ProbeMailEvents` idiom,
// widened to a per-event tally because this arc fires six different events and the
// question is always "did THIS one fire").
// One hidden frame counting each event this arc fires, logging the red and system
// chat texts.
if let Err(e) = script.run(
r#"
if not ProbeAuctionHooked then
@@ -533,11 +425,8 @@ fn auction_probe(
}
let [x, y, z] = AUCTIONEER_AT;
info!("PROBE_AUCTION: hopping to Auctioneer Fitch (entry {AUCTIONEER_ENTRY}) at ({x}, {y}, {z})");
// The teleport (the `ProbeChatPlugin`/`probe_taxi` idiom: GM dot-commands ride as
// plain Say lines). The sell fixture is NOT stocked here: step (5) looks in the bags
// first and only `.additem`s when there is nothing to reuse, because a cancelled
// auction returns its stack by MAIL and an unconditional grant would mint five more
// linen into the world on every run.
// GM commands ride as Say lines. The fixture is not granted here: step (5) reuses a
// stack from the bags first, since each cancel returns its stack by mail.
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
@@ -550,7 +439,7 @@ fn auction_probe(
return;
}
let me = player.pos;
// The NEAREST auctioneer, not the first one the ECS yields — see [`SCAN_RANGE`].
// The nearest auctioneer, not the first the ECS yields.
let nearest = units
.iter()
.filter(|(_, net_e, store, tf)| {
@@ -575,9 +464,7 @@ fn auction_probe(
);
probe.passes += 1;
probe.auctioneer = Some(guid);
// The exact command a right-click on a pure auctioneer builds
// (`target::click::interact_command`, CursorKind::Buy + bit 12). It opens
// NOTHING on its own — the reply does.
// What a right-click on a pure auctioneer sends; the reply opens the window.
let _ = net.0.send(ClientCommand::AuctionHello { auctioneer: guid });
info!("PROBE_AUCTION: (2 greet) MSG_AUCTION_HELLO sent — the window opens on the REPLY, not on this");
probe.phase = Phase::Greet {
@@ -625,8 +512,7 @@ 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.
// The arc's red-line tally starts here.
probe.red_baseline = red_count(&script);
} else {
error!(
@@ -666,9 +552,8 @@ fn auction_probe(
event_baseline,
} => {
if !sent {
// The Browse pane's own call, with every filter at its default: empty name, empty
// level boxes (the pane hands strings), no class/subclass/invtype, page 0, not
// usable-only, quality ALL (-1).
// The Browse pane's call with every filter at its default: empty name and level
// strings, no class/subclass/invtype, page 0, not usable-only, quality all (-1).
if let Err(e) =
script.run(r#"QueryAuctionItems("", "", "", nil, nil, nil, 0, nil, -1)"#)
{
@@ -866,8 +751,7 @@ fn auction_probe(
}
}
Phase::EnsureItem { since, sent } => {
// The bag scan the bag UI itself would do — encoded bag*100+slot, -1 = not there
// (the mail probe's own `GetContainerItemLink` idiom).
// A bag scan by item link, returning bag*100+slot or -1.
let found = script
.eval::<i64>(&format!(
"for bag=0,4 do local n=GetContainerNumSlots(bag) or 0 \
@@ -878,8 +762,7 @@ fn auction_probe(
.unwrap_or(-1);
if found >= 0 {
let (bag, lslot) = (found / 100, found % 100);
// Pick it up and drop it in the sell slot — the two calls the sell button's own
// OnClick chain makes (`AuctionsItemButton_OnClick` → ClickAuctionSellItemButton).
// The two calls the sell button's `AuctionsItemButton_OnClick` chain makes.
if let Err(e) = script.run(&format!(
"PickupContainerItem({bag}, {lslot}) ClickAuctionSellItemButton()"
)) {
@@ -914,9 +797,8 @@ fn auction_probe(
}
}
Phase::Attach { since } => {
// Six values ALWAYS; the first is nil for an empty slot. `price` is the stack's
// vendor value, which is what both deposits are computed from — so wait for it to be
// nonzero rather than quoting a deposit off an item template still in flight.
// Six values, the first nil for an empty slot. Waits for a nonzero `price` (the
// stack's vendor value) so the deposit is not quoted off a template still in flight.
let (name, count, price) = script
.eval::<(String, i64, i64)>(
"local n, _, c, _, _, p = GetAuctionSellItemInfo() return n or \"\", c or 0, p or 0",
@@ -928,8 +810,8 @@ fn auction_probe(
"return CalculateAuctionDeposit({DURATION_MINUTES})"
))
.unwrap_or(-1);
// The sell pane's own suggested opening price (`AuctionSellItemButton_OnEvent`):
// half again the stack's vendor value, floored at one silver.
// The sell pane's suggested opening price: `max(100, floor(price * 1.5))`
// (`AuctionSellItemButton_OnEvent`).
let min_bid = (price * 3 / 2).max(100);
let buyout = min_bid * 2;
let money = store.0.player_money().unwrap_or(0);
@@ -1056,8 +938,7 @@ fn auction_probe(
money_done = true;
}
}
// The slot itself: with the item gone from the bag, the pane must not still be
// showing it.
// With the item gone from the bag, the pane must not still show it.
if !slot_done {
let occupied = script
.eval::<bool>("return GetAuctionSellItemInfo() ~= nil")
@@ -1110,12 +991,8 @@ fn auction_probe(
return n or \"\", b or -1, o or -1, c or -1"
))
.unwrap_or_default();
// The WIRE side lands a frame before the feed pushes the snapshot, so reading Lua
// the moment the entry appears gets the twelve-value null tail — zeros and an
// empty name — for a row that genuinely exists. Wait for the VM to agree rather
// than printing that as if it were the row's content: a PASS whose own message
// shows empty data is the "probe that lies" this file's header warns about, and it
// is the reason the Lua read is part of the assertion below and not decoration.
// The wire entry lands a frame before the feed pushes the snapshot, so Lua reads
// an empty row until the VM agrees; wait for it.
let lua_ready = min_bid == probe.min_bid && buyout == probe.buyout;
if !lua_ready {
if now - since > OWNER_TIMEOUT_SECS {
@@ -1131,9 +1008,8 @@ fn auction_probe(
}
return;
}
// The item's NAME is allowed to still be empty — it fills in from the ask-once
// template cache and the window shows a placeholder until it does. The numbers
// come straight off the wire and must agree on both sides.
// The name may still be empty until the template cache fills it; the numbers
// come off the wire and must agree.
let right = entry.item_entry == ITEM_ENTRY
&& i64::from(entry.start_bid) == probe.min_bid
&& i64::from(entry.buyout) == probe.buyout;
@@ -1178,22 +1054,12 @@ fn auction_probe(
probe.phase = cancel_at(now);
return;
}
// 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.
// Re-ask on a cadence. The first ask is the tab click: every stock caller of
// `GetOwnerAuctionItems` is in the Auctions pane, and its `OnShow` sets the
// `AuctionFrameAuctions.page` the repaint needs (`Blizzard_AuctionUI.lua:813-837`).
if last_ask == 0.0 || now - last_ask > OWNER_REASK_SECS {
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.
// The tab's OnShow queries once per window session (`gotAuctions`).
"AuctionFrameTab_OnClick(3)"
} else {
"GetOwnerAuctionItems()"
@@ -1301,7 +1167,7 @@ fn auction_probe(
_ => return,
}
}
// ...and it has to actually leave the page the window is showing.
// The row must also leave the page the window shows.
if owner_index_of(&auction, auction_id).is_none() {
info!("PROBE_AUCTION: PASS (7b cancel-list) — auction {auction_id} is gone from the \"owner\" list");
probe.passes += 1;
@@ -1317,8 +1183,7 @@ fn auction_probe(
probe.phase = Phase::Done;
return;
}
// The re-ask cadence, plus the write-back that keeps `removed` from re-printing its
// verdict every tick.
// Re-ask on the cadence; writing `removed` back keeps its verdict from re-printing.
let ask = last_ask == 0.0 || now - last_ask > OWNER_REASK_SECS;
if ask {
crate::ui_script::run_or_warn(&script, "GetOwnerAuctionItems()");
@@ -1339,9 +1204,7 @@ fn auction_probe(
"PROBE_AUCTION: DONE pass={} fail={}",
probe.passes, probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// AppExit plus a hard-exit backstop, so a teardown hang cannot keep the account held.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
+40 -98
View File
@@ -1,45 +1,14 @@
//! The bank-arc live probe (`WOW_PROBE_BANK=1`) — decision 0604's end-to-end instrument: log in,
//! GM-hop to a PURE banker, drive the whole six-opcode bank wire
//! (`CMSG_BANKER_ACTIVATE`→`SMSG_SHOW_BANK`, deposit/withdraw via `CMSG_AUTOBANK_ITEM`/
//! `CMSG_AUTOSTORE_BANK_ITEM`, `CMSG_BUY_BANK_SLOT` and its refusal), and print a
//! `PROBE_BANK: <step> PASS/FAIL/SKIP <detail>` line per step plus a final
//! `PROBE_BANK: DONE pass=<n> fail=<m>` summary. Modeled closely on [`super::probe_mail`] (same
//! phase-machine shape, trace style, self-terminating exit) — but unlike mail, the bank steps ride
//! [`ClientCommand`]/the descriptor directly (decision 0604: the vault is already streamed, the
//! 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 bank live probe (`WOW_PROBE_BANK=1`): hops to a pure banker, drives the six bank opcodes
//! (activate, deposit, withdraw, buy a slot and its out-of-range refusal) and logs one
//! `PROBE_BANK: <step> PASS/FAIL/SKIP <detail>` line per step, then `PROBE_BANK: DONE pass=<n>
//! fail=<m>`, and exits. Non-combat; the switches are `docs/CONTRIBUTING.md`, "Running it
//! unattended".
//!
//! ## The banker (live-DB verified against the local vmangos, `characters`/
//! `mangos` DBs)
//! The banker is Soleil Stonemantle (entry 5099, Ironforge), whose `npc_flags` is 256, banker
//! only, so `CMSG_BANKER_ACTIVATE` opens the bank with no gossip in between.
//!
//! Soleil Stonemantle, creature entry 5099, spawn guid 12629, map 0 (Ironforge, The Vault), pos
//! `(-4895.64, -1004.66, 504.024)`. Her `creature_template.npc_flags` is **256** exactly — bit 8
//! only (`UNIT_NPC_FLAG_BANKER`), no gossip bits — so she is a *pure* banker: the direct
//! `CMSG_BANKER_ACTIVATE` route applies with no gossip pre-empt (decision 0604's interact-routing
//! note).
//!
//! ## The funding note — `.modify money` is NOT within a probe account's reach (verified, corrects
//! the task brief)
//!
//! 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 (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
//! first purchase-ladder rung, 1000 copper) and SKIPs the purchase gracefully if the live balance
//! ever falls short of the next rung's price, rather than sending a command known to be refused.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_DATA=WoW/Data WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
//! WOW_PROBE_BANK=1 cargo run -q -p benilla
//! ```
//! (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.
//! The slot purchase funds itself with `.modify money <n>`, which adds `n` copper and needs
//! `SEC_BASIC_ADMIN` (vmangos `Chat.cpp:586`).
use bevy::prelude::*;
@@ -52,32 +21,27 @@ use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectSt
use crate::player::Player;
use crate::ui_bank::{BankOpen, BankPrices};
/// Soleil Stonemantle's spawn (vmangos `creature` guid 12629, entry 5099, map 0) — live-DB
/// verified this session (module doc).
/// Soleil Stonemantle's spawn (vmangos `creature` guid 12629, map 0).
const BANKER_AT: [f32; 3] = [-4895.64, -1004.66, 504.024];
/// Her creature template entry — the streamed-unit identity check.
/// Her creature template entry, one of the two identity checks.
const BANKER_ENTRY: u32 = 5099;
/// `UNIT_NPC_FLAG_BANKER` (bit 8) — the fallback identity check (either suffices; decision 0604).
/// `UNIT_NPC_FLAG_BANKER` (bit 8), the other identity check.
const NPC_FLAG_BANKER: u32 = 0x100;
/// The server's `CheckBanker`/`GetNPCIfCanInteractWith` range is a few yards; scan generously wide
/// so a slightly-off `.go` landing still finds her (the mail probe's `MAILBOX_SCAN_RANGE` idiom).
/// Wider than the server's interact range, so a slightly-off `.go` landing still finds her.
const SCAN_RANGE: f32 = 12.0;
/// The probe's deposit/withdraw fixture: Linen Cloth (cheap, stackable, no durability/equip
/// complications).
/// The deposit/withdraw fixture: Linen Cloth.
const ITEM_ENTRY: u32 = 2589;
/// The first player-array bank slot (`PLAYER_FIELD_BANK_SLOT_1`'s wire index — `SLOT_PACK_FIRST`
/// (23) + the backpack's 16 slots, decision 0604's addressing note: bank slots are wire 39-62).
/// The first bank slot's wire index: `SLOT_PACK_FIRST` (23) plus the backpack's 16; bank slots
/// are 39-62.
const SLOT_BANK_FIRST: u8 = SLOT_PACK_FIRST + 16;
/// The client-side purchase ladder (`BankBagSlotPrices.dbc`, decision 0604) — the fallback used
/// only if [`BankPrices`] failed to load; index = slots already purchased (0-based).
/// `BankBagSlotPrices.dbc`'s ladder, indexed by slots already bought; used only if
/// [`BankPrices`] failed to load.
const PRICE_LADDER: [u32; 6] = [1_000, 10_000, 100_000, 250_000, 500_000, 1_000_000];
/// Slack added on top of the exact shortfall when the buy-slot step funds itself — the purse can
/// move under it (a repair, a vendor sale) between the read and the purchase.
/// Slack over the exact shortfall when the buy-slot step funds itself.
const FUND_MARGIN_COPPER: u32 = 10_000;
/// The purchasable-slot ceiling (decision 0604: `GetNumBankSlots()` reports full at 6).
/// The purchasable-slot ceiling (`GetNumBankSlots()` reports full at 6).
const MAX_BANK_BAGS: u8 = 6;
/// How far past the banker's service range the refusal step teleports (yd, WoW space) — well
/// past the handful of yards `GetNPCIfCanInteractWith` checks.
/// How far (yd, WoW x) the refusal step hops, well past `GetNPCIfCanInteractWith`'s range.
const REFUSAL_OFFSET_X: f32 = 150.0;
const SETTLE_SECS: f64 = 3.0;
@@ -85,8 +49,7 @@ const SCAN_TIMEOUT_SECS: f64 = 15.0;
const ACTIVATE_TIMEOUT_SECS: f64 = 10.0;
const ITEM_TIMEOUT_SECS: f64 = 10.0;
const ACTION_TIMEOUT_SECS: f64 = 8.0;
/// The bonus refusal step's event wait — generous but bounded; a miss SKIPs, never FAILs (task
/// brief: "if flaky, SKIP with a note rather than FAIL").
/// The refusal step's event wait; a miss SKIPs, never FAILs.
const REFUSAL_GRACE_SECS: f64 = 6.0;
pub(crate) struct ProbeBankPlugin;
@@ -98,33 +61,26 @@ impl Plugin for ProbeBankPlugin {
}
}
/// The probe's phase machine + the identities discovered along the way (the mail probe's shape:
/// `Copy` phase, resource-level bulk state so an arm can freely mutate `probe` without fighting
/// the borrow checker over `probe.phase`).
/// The probe's phase machine and the identities it discovers.
#[derive(Resource, Default)]
struct BankProbe {
phase: Phase,
/// The banker's guid, once streamed in.
banker: Option<u64>,
/// The item round-tripped through deposit/withdraw (same guid both ways — an item's object
/// identity survives a bag/slot move server-side).
/// The item round-tripped through deposit/withdraw; its guid survives the move.
item_guid: Option<u64>,
/// The bank slot index (0-based) the fixture item landed in after deposit.
bank_idx: Option<u8>,
/// Step (e)'s baseline purchased-slot count `B`, latched once so the verdict can compare
/// against it after the buy.
/// The buy-slot step's baseline purchased count, latched before the buy.
baseline_purchased: u8,
baseline_money: u32,
/// The next rung's price, resolved once (DBC if loaded, else [`PRICE_LADDER`]).
next_cost: u32,
passes: u32,
fails: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
exited: bool,
}
/// Every field is `Copy` — the phase is snapshotted out of the resource each tick (the mail
/// probe's `let phase = probe.phase;` idiom), freeing the match arms to mutate `probe` freely.
/// `Copy`, so each tick snapshots it and the match arms can mutate `probe`.
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
@@ -137,8 +93,7 @@ enum Phase {
Activating {
sent_at: f64,
},
/// Ensuring a backpack item exists to deposit — `.additem` if the bags are empty (step 3
/// prep).
/// Ensuring a backpack item to deposit, `.additem` if the bags are empty (step 3).
EnsureItem {
since: f64,
sent: bool,
@@ -151,18 +106,15 @@ enum Phase {
Withdraw {
since: f64,
},
/// `BuyBankSlot` sent (funds permitting); waiting for the purchased-count descriptor delta
/// (step 5).
/// `BuyBankSlot` sent; waiting for the purchased-count descriptor delta (step 5).
BuySlot {
since: f64,
sent: bool,
/// Whether this step has already granted itself the fare with `.modify money`. One shot:
/// if the purse is still short after a grant, that is a real defect, not a permission wall.
/// Whether the one `.modify money` grant has gone out; still short after it is a FAIL.
funded: bool,
},
/// Step 6 (bonus): teleport out of range, then `BuyBankSlot` the now-stale guid, expecting
/// `SMSG_BUY_BANK_SLOT_RESULT` NOTBANKER. `teleported`/`bought` gate the two sends in order;
/// `since` resets at each send so the following wait is measured from it, not from entry.
/// Step 6: hop out of range, then `BuyBankSlot` the stale guid, expecting
/// `SMSG_BUY_BANK_SLOT_RESULT` NOTBANKER; `since` resets at each send.
Refusal {
since: f64,
teleported: bool,
@@ -172,22 +124,21 @@ enum Phase {
Done,
}
/// Read the Lua-side `ProbeBankEvents` log length (the `UI_ERROR_MESSAGE` hook, the mail probe's
/// idiom) — `0` on any eval hiccup (treated as "nothing observed yet", never a panic).
/// The length of the Lua `ProbeBankEvents` log (the `UI_ERROR_MESSAGE` hook); `0` on an eval
/// error.
fn events_len(script: &UiScript) -> i64 {
script
.eval::<i64>("return table.getn(ProbeBankEvents or {})")
.unwrap_or(0)
}
/// Read the newest `ProbeBankEvents` entry (the just-fired `UI_ERROR_MESSAGE` text).
/// The newest `ProbeBankEvents` entry.
fn last_event(script: &UiScript) -> String {
script
.eval::<String>("return ProbeBankEvents[table.getn(ProbeBankEvents)] or \"\"")
.unwrap_or_default()
}
/// The first backpack slot holding a nonzero item guid, if any.
fn find_pack_item(store: &ObjectStore) -> Option<(u8, u64)> {
(0..16u8).find_map(|i| {
store
@@ -198,7 +149,6 @@ fn find_pack_item(store: &ObjectStore) -> Option<(u8, u64)> {
})
}
/// The bank slot index currently holding `guid`, if any.
fn find_bank_slot(store: &ObjectStore, guid: u64) -> Option<u8> {
(0..24u8).find(|&i| store.0.player_bank_slot(i) == Some(guid))
}
@@ -220,7 +170,7 @@ fn bank_probe(
return; // not in-world yet
}
let Some(script) = script else {
return; // no UI VM this build (headless net-only) — nothing this probe can drive
return; // no UI VM in a headless build
};
let now = time.elapsed_secs_f64();
let phase = probe.phase;
@@ -230,8 +180,7 @@ fn bank_probe(
match phase {
Phase::Wait => {
// The UI_ERROR_MESSAGE hook (step 6's observation channel — the mail probe's exact
// pattern), installed up front so it's live well before the refusal step needs it.
// Step 6's `UI_ERROR_MESSAGE` hook, installed up front.
if let Err(e) = script.run(
r#"
if not ProbeBankHooked then
@@ -439,12 +388,8 @@ fn bank_probe(
rung costs {cost}c"
);
if money < cost && !funded {
// Fund the rung and come back next tick. `.modify money <n>` ADDS `n` copper
// (vmangos `HandleModifyMoneyCommand`: the arg is `addmoney`, not a set) and
// needs SEC_BASIC_ADMIN(4) — which probe accounts have had since they were
// actually raised to 6 (0651). This step used to SKIP here, because the
// accounts were gmlevel 3 and the grant would have been refused; the whole
// buy-slot leg was therefore unverified whenever the purse ran dry.
// Fund the rung and come back next tick; `.modify money <n>` adds `n`
// copper (vmangos `CharacterCommands.cpp:4477`).
let grant = cost - money + FUND_MARGIN_COPPER;
info!("PROBE_BANK: (5 buy_slot) purse {money}c < {cost}c — granting {grant}c with `.modify money`");
let _ = net.0.send(ClientCommand::Chat {
@@ -547,8 +492,7 @@ fn bank_probe(
return;
}
if !bought {
// A settle window before firing the stale-guid buy — mirrors step 1's
// SETTLE_SECS, long enough for the `.go` + the range-guard close to land.
// Let the `.go` and the bank window's range close land first.
if now - since < SETTLE_SECS {
return;
}
@@ -595,9 +539,7 @@ fn bank_probe(
"PROBE_BANK: DONE pass={} fail={}",
probe.passes, probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `AppExit` plus a hard backstop, so a teardown hang cannot keep the account held.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
+155 -464
View File
@@ -1,99 +1,27 @@
//! The **inside-a-battleground** live probe (`WOW_PROBE_BG=wsg|ab|av`) — the instrument that turns
//! "does a battleground work?" from a question nobody can answer into a census anyone can read.
//! Inert without the env.
//! The inside-a-battleground live probe (`WOW_PROBE_BG=wsg|ab|av`): level, greet a battlemaster,
//! queue, take the port through the stock `AcceptBattlefieldPort`, print a census every
//! [`CENSUS_EVERY`] from inside, then take the flag and die for the graveyard leg. Inert without
//! the env.
//!
//! ## Why this exists
//! One body can never fill a battleground (`battleground_template.min_players_per_team`), so the
//! probe sends `.debug bg`, which toggles `BattleGroundMgr::m_testing` and lets a match form with
//! one player on either side (`BattleGroundMgr.cpp:557/623/756`). It is a realm-wide, in-memory
//! toggle announced to every player, so the probe counts its sends ([`BgProbe::toggles_sent`]) and
//! [`leave_testing_as_found`] restores the parity on every road to [`Phase::Done`]. It goes back
//! off once inside: while testing is on, the premature-finish countdown never decrements
//! (`BattleGround.cpp:337`).
//!
//! Decisions 1963 / 1972 / 1974 / 1980 built the whole battleground **interface** — the queue, the
//! list window, the scoreboard, the always-up readout, the map blips, forty-odd Lua verbs — and
//! every one of them off the reference's own FrameXML. What none of them could build is the thing
//! on the far side of the port button: **nothing in this tree has ever entered map 489, 529 or
//! 30.** `ProbeBgQueuePlugin` stops at the queue by design (it is 2232's login-burst fixture);
//! the terrain, the world states, the objects, the spirit guides and the match's own end have
//! never been observed at all. So the gaps are not *known* to be gaps — they are unmeasured, and
//! an unmeasured subsystem is where a "should work by construction" quietly isn't.
//! A census reports the map, area and position, terrain streaming, entity counts by kind, the
//! gameobject entries, the raw world-state table, `GetNumWorldStateUI()`,
//! `GetBattlefieldStatus(1..3)`, the scoreboard, the nearest spirit guide, the dropped opcodes and
//! the Lua event tap's window.
//!
//! This probe is the measurement. It walks the real player road — level, greet a battlemaster on
//! the wire, queue, take the port through the **stock Lua verb** the reference's own dialog calls
//! — and then, from inside, prints a structured census every [`CENSUS_EVERY`] until
//! [`census_samples`] are in. Every later battleground round is judged against these lines.
//! The probe stamps input every frame, so it never exercises the idle handler.
//!
//! ## The one server-side lever, and why it is needed — and why it is turned back off
//!
//! A battleground does not start until `min_players_per_team` bodies are queued on each side —
//! four for Warsong Gulch on this server's patch, twenty for Alterac Valley
//! (`mangos.battleground_template`). One probe can never reach that. vmangos has the lever built
//! in: **`.debug bg`** toggles `BattleGroundMgr::m_testing`, and `BattleGroundQueue::CheckNormalMatch`
//! then starts a battleground as soon as *either* side has one player
//! (`BattleGroundMgr.cpp:557/623/756`). It is `SEC_ADMINISTRATOR` (6), exactly what a probe account
//! holds, and it is **in-memory only** — `m_testing` is initialised `false` in the manager's
//! constructor, so a server restart clears it whatever happens here.
//!
//! `.debug bg` is a **toggle, not a set**, and it announces itself to the whole realm
//! (`LANG_DEBUG_BG_ON`/`_OFF`). Both facts are reported on the probe's own lines so a run that
//! started from the wrong state reads as such rather than as a broken queue.
//!
//! **The lever is tracked by PARITY, not by state** ([`BgProbe::toggles_sent`]), and put back by
//! [`leave_testing_as_found`] — which every road to [`Phase::Done`] goes through, failures
//! included. "What is it now?" can only be answered by a world-text reply that may never arrive;
//! "how far from where I found it?" needs no reply at all. It is restored **as soon as the probe
//! is inside the instance**, because that is the moment the lever has finished its job: it is
//! needed for `CheckNormalMatch` to *form* the match, not to run one.
//!
//! Leaving it on also makes a solo battleground unendable: vmangos decrements its premature-finish
//! countdown inside an `else if (!sBattleGroundMgr.isTesting())` branch (`BattleGround.cpp:337`),
//! so while testing is on the countdown is armed and then **frozen**. Decision 2290 said the
//! opposite, from reading `GetPrematureFinishTime()` without the branch that consumes it; 2296
//! corrects it, having watched a run sit in Warsong Gulch for 492 s with `GetBattlefieldWinner()`
//! nil the whole way.
//!
//! ## What the census reports, and why each column is there
//!
//! | column | the question it answers |
//! |---|---|
//! | `map` / `area` | did the worldport land, and does the terrain know where we are? |
//! | `pos` | is the body at the battleground's own start location, or at the origin? |
//! | `terrain` | [`WorldLoadProgress`] — did map 489's ADTs stream, or is the ground missing? |
//! | `ents` | units / players / gameobjects / dynamic / corpses actually mirrored into the ECS |
//! | `gos` | the gameobject **entries** in range — the flags, the doors, the banners |
//! | `states` | the raw world-state table and its scope: the score, the flag carriers, the timer |
//! | `alwaysup` | `GetNumWorldStateUI()` — what the reference's readout would actually draw |
//! | `status` | `GetBattlefieldStatus(1..3)`, straight out of the VM |
//! | `score` | `GetNumBattlefieldScores()` / `GetBattlefieldWinner()` |
//! | `guide` | the nearest `SPIRITGUIDE`-flagged unit — the graveyard's resurrect wave |
//! | `dropped` | every opcode the codec threw on the floor, by name |
//! | `events` | the Lua event tap's window — or an explicit "the tap is broken", never silence |
//! | `mapinfo` | (at Report) `GetMapInfo()` and the overlay/landmark counts — whether the battle
//! map has anything to **draw**, which `IsShown()` cannot tell you |
//!
//! There is deliberately **no `lua` column**; see the note at the end of [`census`] for why the one
//! that used to be here could not measure what it claimed.
//!
//! ## What this probe cannot see
//!
//! It calls [`crate::ui_chat::idle::LastInput::stamp_present`] every frame, which suppresses the
//! whole idle band — auto-sit, auto-AFK and camp alike (`ui_chat::idle::idle_action`). That is
//! deliberate and necessary (see the note at the top of [`bg_probe`]), but it means the one
//! long-running instrument that otherwise sits still for minutes can never exercise the idle
//! handler. A regression there has to be caught somewhere else.
//!
//! ## The run recipe
//!
//! ```sh
//! cd <slot> && WOW_USER=probe7 WOW_PASS=pprobe7 WOW_CHAR=Probeseven \
//! WOW_UNATTENDED=1 WOW_NOSOUND=1 WOW_GM=off WOW_PROBE_BG=wsg \
//! timeout 600 cargo run -p benilla 2>&1 | grep -E 'PROBE bg:'
//! ```
//!
//! Raise the `timeout` with [`census_samples`]: a default run needs ~5 min including login, and a
//! run sized to reach a match's ending needs ~10.
//!
//! `WOW_GM=off` is not optional here: GM mode re-templates the body's faction to 35, and a
//! battleground is the one place where every reaction, every objective and the server's own team
//! 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 checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill).
//! Run it with `WOW_GM=off` (GM mode re-templates the body's faction to 35, and a battleground
//! reads every reaction and the team assignment off it) under a `timeout` of about 5 min, or 10
//! for a run sized by `WOW_PROBE_BG_SAMPLES` to reach a match's end; the rest is
//! `docs/CONTRIBUTING.md`, "Running it unattended". Non-combat: the probe never attacks.
use bevy::ecs::system::NonSendMut;
use bevy::prelude::*;
@@ -112,31 +40,24 @@ use crate::ui_battlefield::Battlefield;
use crate::ui_dialog_verbs::BattlefieldQueue;
use crate::world_state::WorldStates;
/// One battleground's fixtures: what to queue for, and which battlemaster to greet.
///
/// All three battlemasters stand within ~35 yd of each other in Stormwind's PvP alcove, so one
/// `.go` reaches any of them — which is why the probe finds its NPC by **creature entry** off the
/// guid rather than by "the nearest battlemaster", a predicate that would pick whichever of the
/// three streamed in first.
/// One battleground's fixtures. The three battlemasters stand within ~35 yd of each other in
/// Stormwind, so the probe finds its NPC by creature entry, not by nearness.
struct Arena {
/// `WOW_PROBE_BG`'s value.
key: &'static str,
/// The Map.dbc row — what `CMSG_BATTLEMASTER_JOIN` carries and what the worldport must land on.
/// The Map.dbc row the join carries and the worldport lands on.
map: u32,
/// The battlemaster's `creature_template.entry` (vmangos `battlemaster_entry`).
/// The battlemaster's `creature_template.entry`.
npc_entry: u32,
/// That NPC's display name, for the probe's own lines.
npc_name: &'static str,
/// Its spawn — the `.go xyz` target (vmangos `creature`, map 0, Stormwind).
/// The battlemaster's spawn on map 0, the `.go xyz` target.
at: [f32; 3],
/// The objective this battleground's **flag leg** goes for: the gameobject entry to click and
/// where it stands on the battleground map (vmangos `gameobject`). `None` for a battleground
/// whose objective is not a single clickable flag.
/// The flag leg's gameobject entry and its position on the battleground map; `None` where the
/// objective is not one clickable flag.
flag: Option<(u32, [f32; 3])>,
}
/// The three 1.12 battlegrounds. Levels are not per-arena: [`QUEUE_LEVEL`] clears every bracket
/// floor on every content patch, so the probe never has to know which patch the server runs.
/// The three 1.12 battlegrounds; [`QUEUE_LEVEL`] clears every bracket floor on every patch.
const ARENAS: [Arena; 3] = [
Arena {
key: "wsg",
@@ -144,8 +65,8 @@ const ARENAS: [Arena; 3] = [
npc_entry: 14981,
npc_name: "Elfarran",
at: [-8454.62, 318.85, 120.97],
// The **Warsong Flag** at the Horde base — the one an Alliance body carries. Our own
// Silverwing Flag (179830) is the capture point, not the pickup.
// The Warsong Flag at the Horde base, the one an Alliance body carries; the Silverwing
// Flag (179830) is the capture point.
flag: Some((179831, [916.02, 1434.40, 345.41])),
},
Arena {
@@ -154,7 +75,7 @@ const ARENAS: [Arena; 3] = [
npc_entry: 15008,
npc_name: "Lady Hoteshem",
at: [-8420.48, 328.71, 120.89],
// Arathi Basin's objective is five capturable banners, not a carried flag.
// Arathi Basin's objectives are five banners, not a carried flag.
flag: None,
},
Arena {
@@ -168,81 +89,42 @@ const ARENAS: [Arena; 3] = [
},
];
/// The level the probe body is raised to.
///
/// 60 rather than the 25 `probe_bg_queue` uses, because Alterac Valley's floor is **51** on every
/// patch that ships it and the bracket is checked at the HELLO — a body under it is refused there,
/// silently, so the list never arrives and the failure reads like a broken client.
/// The probe body's level: Alterac Valley's floor is 51, and a body under the bracket is refused
/// at the hello with no reply.
const QUEUE_LEVEL: u32 = 60;
/// How many times to drop and re-take the queue before giving up (see the `Joined` arm).
/// How many times to drop and re-take the queue before giving up.
const MAX_REJOINS: u32 = 3;
/// The `.go`'s settle radius before the battlemaster scan. Not a gate — the server applies its own
/// interaction check; this only keeps the probe from greeting a battlemaster it has not reached.
/// The settle radius before greeting; the server applies its own interaction range.
const NEAR_YD: f32 = 20.0;
/// `PLAYER_FLAGS_GM` — the bit that says the body carries GM mode's faction-35 re-template
/// (`crate::probe_shield` names the same constant).
/// `PLAYER_FLAGS_GM`: the body carries GM mode's faction-35 re-template.
const PLAYER_FLAGS_GM: u32 = 0x0000_0008;
/// How long the exit waits for the battleground leave to land before going anyway. The teleport
/// out is a server round trip plus a worldport; five seconds is far more than a local server needs
/// and is a backstop, not a budget.
/// How long the exit waits for the leave's worldport before going anyway.
const LEAVE_GRACE: f64 = 5.0;
/// How long to wait for `probe_shield`'s `.gm off` to land before refusing the run.
///
/// **Sized by that module's own sequencing, not by guesswork** — five seconds was measured as too
/// tight and refused a run that had `WOW_GM=off` set and was about to comply. `probe_shield` waits
/// up to `NAME_WAIT_SECS` (3 s) for the body's name to resolve, then spaces its commands
/// `STEP_SECS` (0.8 s) apart with the god line *before* the `.gm off` (the shield must be up first,
/// 0679), and the flag only clears once the server's field update comes back. Twenty seconds
/// clears all of that with room, and still fails fast against a run that is never going to comply.
/// How long to wait for `probe_shield`'s `.gm off` to land: it waits up to 3 s for the body's
/// name, sends its commands 0.8 s apart with `.gm off` after the god line, and the flag clears
/// only on the server's field update.
const GM_DROP_WAIT: f64 = 20.0;
/// How long to keep listening for `.debug bg`'s world-text reply before giving up and saying so.
///
/// The reply crosses the network, the chat pipeline and the Lua event dispatch before it reaches
/// the tap. The old code took **one** destructive look 1.5 s after the send, which is both too
/// narrow to be reliable and, being a single sample, unable to tell "not yet" from "never".
/// How long to listen for `.debug bg`'s world-text reply, which crosses the network, the chat
/// pipeline and the Lua event dispatch before it reaches the tap.
const TOGGLE_REPLY_WINDOW: f64 = 6.0;
/// How often a census line is printed once inside.
const CENSUS_EVERY: f64 = 12.0;
/// How many census samples to take before reporting and leaving.
///
/// **Sized by the preparation phase, not by taste.** A battleground does not begin when you land
/// in it: vmangos holds every arrival behind closed doors for `BG_START_DELAY_2M` = **120 s**
/// (`BattleGround.cpp:248`, the four `BG_STARTING_EVENT_*` steps at 2 min / 1 min / 30 s / go),
/// and `.debug bg` does not shorten it. A window that ended before then would census nothing but
/// the pen and report it as the whole battleground.
///
/// Twelve × 12 s = **144 s**: the full prep, the doors dropping at ~116 s, and two samples past
/// them, which leaves room for the flag and graveyard legs inside the same run. Reaching the
/// *ending* is a different window — see [`census_samples`].
/// 12 × 12 s = 144 s: vmangos holds arrivals behind closed doors for `BG_START_DELAY_2M` (120 s,
/// `BattleGround.cpp:248`), which `.debug bg` does not shorten, plus two samples past the doors.
const CENSUS_SAMPLES_DEFAULT: u32 = 12;
/// The census sample count, overridable with `WOW_PROBE_BG_SAMPLES`.
///
/// The default covers the preparation phase and the doors. **The reason it is a dial** is the end
/// of a match: with one body inside, vmangos starts its `BattleGround.PrematureFinishTimer` (5 min,
/// because `CreateNewBattleGround` reads `min_players_per_team` from the template and not from the
/// testing override, so a solo match is permanently under-populated) and then ends the battleground
/// **with no winner** — `WINNER_NONE` = 2, not a team (`BattleGroundDefines.h:204`).
///
/// **Sizing it is arithmetic, not taste.** The countdown is armed when the battleground reaches
/// `STATUS_IN_PROGRESS` — the doors, ≈T+120 s — and runs 300 s from there, so the ending lands at
/// ≈T+420 s ≈ **35 samples**. `WOW_PROBE_BG_SAMPLES=36` reaches it; the 30 this doc used to
/// recommend stops 60 s short and reports "the match never ended", which reads as a defect rather
/// than as a window that was too small.
///
/// **Alterac Valley can never be ended this way at all**: the whole premature-finish block is
/// guarded by `GetTypeID() != BATTLEGROUND_AV` (`BattleGround.cpp:318`).
///
/// A value below 1 is meaningless and is clamped to 1 — the first sample is also where the testing
/// lever is put back, and a run that never censuses never restores it.
/// The census sample count, `WOW_PROBE_BG_SAMPLES`, at least 1 (the first sample restores the
/// testing lever). A solo match stays under the template's `min_players_per_team`, so vmangos ends
/// it with no winner (`WINNER_NONE` = 2, `BattleGroundDefines.h:204`) 300 s after the doors,
/// about T+420 s: 36 samples reach it. Alterac Valley never ends this way (`BattleGround.cpp:318`).
fn census_samples() -> u32 {
std::env::var("WOW_PROBE_BG_SAMPLES")
.ok()
@@ -251,9 +133,8 @@ fn census_samples() -> u32 {
.max(1)
}
/// How often the ghost leg samples, and for how long. The battleground resurrect wave is a 30 s
/// server cycle (`BattleGround::Update`'s `m_lastResurrectTime`), so eleven 4 s samples cover a
/// full wave and a little of the next — enough to see the clock arm, tick down, and fire.
/// The ghost leg's sample period: the resurrect wave is a 30 s server cycle
/// (`BattleGround::Update`'s `m_lastResurrectTime`), so eleven 4 s samples cover one full wave.
const GHOST_SAMPLE_EVERY: f64 = 4.0;
/// See [`GHOST_SAMPLE_EVERY`].
const GHOST_SAMPLES: u32 = 11;
@@ -261,22 +142,14 @@ const GHOST_SAMPLES: u32 = 11;
/// How many 3 s samples the flag leg takes after the use.
const FLAG_SAMPLES: u32 = 5;
/// `SPIRITGUIDE` — `UNIT_NPC_FLAGS` bit 6, the flag the reference's area-spirit-healer acquire
/// scan `0x4924c0` keys on.
/// `SPIRITGUIDE`, `UNIT_NPC_FLAGS` bit 6: the flag the reference's area-spirit-healer 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
/// event the reference's own interface listens for, with its `arg1`.
///
/// This exists because the first run's finding could not be read off the log at all. vmangos sends
/// three `CHAT_MSG_BG_SYSTEM_NEUTRAL` lines during the countdown (`BattleGround.cpp:383/392/400`,
/// the one-minute / half-minute / has-begun `m_startMessageIds`) and a `SMSG_PLAY_SOUND`
/// (`PlaySoundToAll(SOUND_BG_START)`), and **nothing appeared** — but an absent log line is not an
/// absent event, because a chat kind that reaches the VM logs nothing on the way. Silence in a log
/// is not evidence; a tap on the event itself is.
///
/// It is written in the reference's own Lua dialect (`this`/`event`/`arg1` globals in `OnEvent`,
/// no `ipairs` over a literal) so it exercises the same road a 1.12 addon would.
/// A Lua frame that records every battleground event the stock interface listens for, with its
/// `arg1`: a chat kind that reaches the VM logs nothing, so only a tap shows the countdown's
/// `CHAT_MSG_BG_SYSTEM_NEUTRAL` lines (`BattleGround.cpp:383-418`). Written in 1.12 dialect
/// (`this`/`event`/`arg1` globals, no `ipairs` over a literal).
const EVENT_TAP: &str = r#"
BenillaBgLog = {};
BenillaBgTap = CreateFrame("Frame");
@@ -299,18 +172,12 @@ BenillaBgTap:RegisterEvent("PLAYER_ALIVE");
BenillaBgTap:RegisterEvent("ZONE_CHANGED_NEW_AREA");
"#;
/// Drain the tap: everything it recorded since the last census, then empty it.
///
/// **It reports the tap's own liveness first**, and that is not decoration. `BenillaBgLog` is set
/// on `EVENT_TAP`'s first line, so a tap that raised *after* that line — or a tap installed into a
/// VM that has since been thrown away and minted fresh — leaves a table that drains cleanly to `""`
/// forever. An empty string would then be indistinguishable from "the battleground fired nothing",
/// which is the one answer this instrument must never give by accident. The sentinel makes the
/// difference readable, and [`bg_probe`] re-installs on seeing it.
/// What the drain returns when the tap's globals are gone (the VM was minted fresh), so a dead tap
/// never reads as "no events"; [`bg_probe`] re-installs on seeing it.
const TAP_GONE: &str = "<tap-gone>";
/// See [`TAP_GONE`]: both globals are checked, because the frame is what actually receives events
/// and the table is what holds them — either one missing means the readings are not trustworthy.
/// Drain the tap since the last census and empty it; both globals are checked, the frame that
/// receives and the table that holds.
const EVENT_DRAIN: &str = r#"
local out = "";
if not BenillaBgLog or not BenillaBgTap then return "<tap-gone>" end
@@ -331,52 +198,33 @@ impl Plugin for ProbeBgPlugin {
#[derive(Resource, Default)]
struct BgProbe {
phase: Phase,
/// The Lua event tap is in and believed live. **Not a latch** — the drain re-checks the tap's
/// own global every sample and clears this if the VM went away underneath it, because the
/// interface VM is minted fresh on every world entry (`ui_script::lifecycle::mint_entry_vm`)
/// and a tap installed into the boot VM is silently gone after the port. A dead tap that still
/// reads `tap_installed` prints `EVENTS (none)` forever, which is the exact inversion this
/// instrument exists to avoid.
/// The Lua event tap is in and believed live; cleared when a drain finds it gone, because the
/// interface VM is minted fresh on every world entry (`ui_script::lifecycle::mint_entry_vm`).
tap_installed: bool,
/// The tap raised on install and cannot be trusted. Kept apart from `tap_installed` so an
/// `EVENTS` line can say *"the tap is broken"* rather than *"the battleground fired nothing"*.
/// The tap raised on install, so an `EVENTS` line says the tap is broken, not that nothing
/// fired.
tap_broken: bool,
/// **How many `.debug bg` toggles this run has sent — parity, not state.**
///
/// `.debug bg` is a toggle, so "what is it now?" is the wrong question and the one the old code
/// asked: it can only be answered by reading a world-text reply that may never arrive. "How far
/// from where I found it?" is always answerable, needs no reply, and is what a restore actually
/// needs. An odd count means this run left the lever flipped and owes the realm one more send;
/// an even count means it is already as found — including the normal road, where the run turns
/// testing on to queue and off again once inside.
/// How many `.debug bg` toggles this run has sent: an odd count owes the realm one more send.
/// Parity needs no reply; the lever's state can only be read from a reply that may never come.
toggles_sent: u32,
/// What the tap last said the lever reads: `Some(true)` = on ("…for debugging"), `Some(false)` =
/// off ("…normal playercount"), `None` = no reply seen yet. Consumed once so a second pass
/// cannot re-decide on a stale marker.
/// What the tap last said the lever reads: on ("…for debugging"), off ("…normal
/// playercount"), or no reply yet; consumed once.
testing_seen: Option<bool>,
/// The lever was **read back** as on before the join, rather than merely assumed. The `Joined`
/// failure text says which, because "the flag was confirmed ON, so look elsewhere" sends the
/// reader past the actual suspect when nothing was ever confirmed.
/// The lever was read back as on before the join, not assumed; the `Joined` failure says which.
testing_confirmed: bool,
/// How many times the queue has been dropped and re-taken (see the `Joined` arm).
rejoins: u32,
/// The `AppExit` has been written — `Done` is re-entered every frame until the app actually
/// stops, and a second exit write would be noise.
/// The `AppExit` has been written; `Done` is re-entered every frame until the app stops.
exited: bool,
/// When [`Phase::Done`] was first seen, so the exit can wait for the leave to land.
done_at: Option<f64>,
/// When the `Wait` arm first saw a body, so the GM-mode gate can time out rather than hang.
/// When the `Wait` arm first saw a body, so the GM-mode gate can time out.
waiting_since: Option<f64>,
/// Events drained before the first census (during the toggle read-back and the queue road).
/// Carried rather than discarded: the old code threw them away and then attributed them to the
/// first 12 s census window, which is a reading about the wrong interval.
/// Events drained before the first census, reported with it rather than dropped.
pending_events: String,
}
/// `Wait` → (levelled, hop sent) `Hopped` → (`.debug bg` sent) `Toggled` → (reply read, testing
/// confirmed on, battlemaster greeted) `Greeted` →
/// (join sent) `Joined` → (the port taken) `Ported` → (map 489 reached) `Inside` →
/// (`.die` sent) `Dying` → (released, ghost at the graveyard) `Ghost` → `Done`.
/// `Wait` → `Hopped` → `Toggled` → `Scanning` → `Greeted` → `Joined` (↔ `Rejoining`) → `Ported` →
/// `Inside` → `FlagHop` → `FlagUsed` (a flag arena only) → `Dying` → `Ghost` → `Done`.
#[derive(Default, PartialEq)]
enum Phase {
#[default]
@@ -388,10 +236,7 @@ enum Phase {
sent_at: f64,
corrected: bool,
},
/// Looking for the battlemaster, **after** the testing lever is settled. Split out of `Toggled`
/// so the reply read happens once: the old code re-ran its destructive drain every frame of the
/// 15 s scan, which threw away every tap event that arrived during it and re-printed a
/// "TESTING unread" warning over the "TESTING on" line it had already printed.
/// Looking for the battlemaster once the testing lever is settled.
Scanning {
since: f64,
},
@@ -431,8 +276,7 @@ enum Phase {
Done,
}
/// Which battleground this run is for — `WOW_PROBE_BG`'s value, defaulting to Warsong Gulch (the
/// smallest, fastest and the only one whose objectives fit in one census window).
/// The battleground `WOW_PROBE_BG` names, Warsong Gulch by default.
fn arena() -> &'static Arena {
let want = std::env::var("WOW_PROBE_BG").unwrap_or_default();
ARENAS
@@ -441,8 +285,7 @@ fn arena() -> &'static Arena {
.unwrap_or(&ARENAS[0])
}
/// A GM dot-command on the real chat wire; every reply is echoed by `net` as
/// `server says — …`, so a command that did nothing is visible as such.
/// A GM dot-command on the chat wire; `net` logs every reply.
fn gm(net: &NetCommands, text: impl Into<String>) {
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
@@ -451,12 +294,8 @@ fn gm(net: &NetCommands, text: impl Into<String>) {
});
}
/// Drain the event tap, distinguishing its three outcomes rather than flattening them to `""`.
///
/// Returns the events, or a bracketed reason. A `TAP_GONE` sentinel also **clears
/// `tap_installed`**, so the next frame re-installs into whatever VM is live now — which is the
/// self-heal for the one-frame world-entry window where the tap can land in a boot VM that is
/// about to be discarded (`ui_script::lifecycle`).
/// Drain the event tap: the events, or a bracketed reason. [`TAP_GONE`] clears `tap_installed` so
/// the next frame re-installs into the live VM.
fn drain_events(probe: &mut BgProbe, script: &mut UiScript) -> String {
match script.eval::<String>(EVENT_DRAIN) {
Ok(s) if s.trim() == TAP_GONE => {
@@ -468,15 +307,8 @@ fn drain_events(probe: &mut BgProbe, script: &mut UiScript) -> String {
}
}
/// Put the realm's `.debug bg` lever back if **this run** is the one that moved it.
///
/// Every road to [`Phase::Done`] goes through this, and that is the whole point. `.debug bg` is a
/// realm-global, in-memory toggle (`BattleGroundMgr::ToggleTesting`) that announces itself to every
/// player in the world, and it is a **toggle, not a set** — so a run that ends without restoring it
/// hands the next run the opposite of what it expects. That is not hypothetical: it is how runs 2,
/// 4 and 5 of this probe failed (2290 §2). The old code restored it at exactly one place, an
/// equality test on the census counter, which every failure path and every
/// `WOW_PROBE_BG_SAMPLES < DOORS_SAMPLE` run skipped silently.
/// Put the realm's `.debug bg` lever back if this run moved it. Every road to [`Phase::Done`] goes
/// through here: the command is a realm-wide toggle (`BattleGroundMgr::ToggleTesting`), not a set.
fn leave_testing_as_found(probe: &mut BgProbe, net: &NetCommands) {
if probe.toggles_sent % 2 == 1 {
info!(
@@ -493,8 +325,7 @@ fn leave_testing_as_found(probe: &mut BgProbe, net: &NetCommands) {
}
}
/// Send `.debug bg` and count it. **Every** send goes through here — the parity in
/// [`leave_testing_as_found`] is only as good as the counting.
/// Send `.debug bg` and count it; every send must go through here for the restore's parity.
fn toggle_testing(probe: &mut BgProbe, net: &NetCommands) {
probe.toggles_sent += 1;
gm(net, ".debug bg");
@@ -506,14 +337,9 @@ fn finish(probe: &mut BgProbe, net: &NetCommands) {
probe.phase = Phase::Done;
}
/// A world state, distinguishing **absent** from **zero**.
///
/// [`WorldStates::get`] is reference-faithful: a key the server never sent reads `0`, because that
/// is what the real client's table does. That is right for the client and wrong for an instrument —
/// `ws2338=0` reads as a well-formed "the flag is at its base" when it actually means "no
/// `SMSG_INIT_WORLD_STATES` ever carried this key". vmangos writes only 1 or 2 into the flag rows
/// (`BattleGroundWS.cpp:509-515`, all eight call sites), so a `0` there is *always* a missing
/// reading — and the person reading the log is exactly the person who does not know that yet.
/// A world state, or `absent`: [`WorldStates::get`] reads a never-sent key as `0`, as the
/// reference's table does, and vmangos writes only 1 or 2 into the flag rows
/// (`BattleGroundWS.cpp:509-515`).
fn ws(states: &WorldStates, key: u32) -> String {
states
.pairs()
@@ -521,7 +347,6 @@ fn ws(states: &WorldStates, key: u32) -> String {
.map_or_else(|| "absent".to_string(), |(_, v)| v.to_string())
}
// One Bevy system's full input set — the guard-poi probe's shape, plus the census reads.
#[allow(clippy::too_many_arguments)]
fn bg_probe(
time: ProbeClock,
@@ -547,20 +372,12 @@ fn bg_probe(
let arena = arena();
let now = time.elapsed_secs_f64();
// **This probe stands in for a player who is AT the keyboard.** benilla implements the
// reference's idle handler faithfully (`ui_chat::idle`: auto-sit then auto-AFK at 300 000 ms
// of no input), and vmangos removes an AFK player from a battleground outright
// (`Player::ToggleAFK` → `LeaveBattleground`). Two correct behaviours, one on each side, and
// together they eject an unattended probe five minutes in — which is exactly what the first
// long run hit: at t=300 s the census read Stormwind, not Warsong Gulch, and the chat said
// "You are now AFK". A battleground match cannot be watched to its end without this.
// The probe stands in for a player at the keyboard: auto-AFK comes at 300 s of no input
// (`ui_chat::idle`), and vmangos removes an AFK player from a battleground
// (`Player.cpp:1741-1742`).
idle.stamp_present(std::time::Duration::from_secs_f64(now));
// **The tap goes in as soon as there is a VM to put it in, and not before.** The probe's own
// first frame is a world-entry frame: the in-game interface has not materialized yet, so an
// install at Setup gets `no VM` and every later `EVENTS` line reads `(none)` — which looks
// exactly like "the battleground fired no events" and is the most misleading answer this
// instrument could give. Retried every frame until it lands; one bool test once it has.
// The tap goes in on the first frame with a VM; the world-entry frame has none yet.
if !probe.tap_installed {
if let Some(script) = script.as_deref_mut() {
match script.eval::<()>(EVENT_TAP) {
@@ -569,8 +386,8 @@ fn bg_probe(
info!("PROBE bg: TAP installed");
}
Err(e) => {
probe.tap_installed = true; // a raise will not fix itself; say so once
probe.tap_broken = true; // …and every later EVENTS line says so too
probe.tap_installed = true; // a raise will not fix itself
probe.tap_broken = true;
error!("PROBE bg: TAP FAILURE — {e}");
}
}
@@ -579,20 +396,9 @@ fn bg_probe(
match probe.phase {
Phase::Wait => {
// **GM mode makes every objective in this probe fail silently, so refuse to start in
// it.** vmangos's `Player::CanUseBattleGroundObject` opens with
// `if (IsGameMaster()) return false;` (`Player.cpp:20576`), which means the flag click,
// the Arathi Basin banners and Alterac Valley's towers are all rejected server-side
// with no reply — while the queue, the port, the census, the world states and the
// scoreboard all keep working perfectly. That is the worst possible failure shape: a
// run that looks 90% right and is wrong exactly where the objectives are.
//
// MEASURED, by getting it wrong: a run without `WOW_GM=off` reached the Warsong Flag at
// 1.8 yd, sent `CMSG_GAMEOBJ_USE`, and reported `ws2339=1` with no banner aura and no
// `CHAT_MSG_BG_SYSTEM_ALLIANCE` — indistinguishable, from the probe's own lines, from a
// client that cannot pick up flags. `probe_shield` drops GM mode when `WOW_GM=off` is
// set (0679), so this WAITS for the flag to clear rather than refusing outright: the
// shield arms and drops GM a few hundred ms into the world, after this arm first runs.
// No start in GM mode: `CanUseBattleGroundObject` refuses a GM every objective with no
// reply (`Player.cpp:20576`) while the queue and census still work. Under
// `WOW_GM=off`, `probe_shield` drops GM mode a moment into the world, so this waits.
if store.0.player_flags() & PLAYER_FLAGS_GM != 0 {
let since = *probe.waiting_since.get_or_insert(now);
if now - since < GM_DROP_WAIT {
@@ -610,28 +416,20 @@ fn bg_probe(
finish(&mut probe, &net);
return;
}
// Revive first, unconditionally: `CanInteractWithNPC` refuses a dead player outright,
// and the refusal surfaces as an anticheat line about an "invalid creature" — which
// reads like a wrong guid, not like a corpse (`probe_bg_queue`'s note).
// Revive first: `CanInteractWithNPC` refuses a dead player, logged as an "invalid
// creature".
gm(&net, ".revive");
// **Wait for the real level rather than assuming one.** `unit_level()` is `None` until
// `UNIT_FIELD_LEVEL` is in the store, and this arm fires on the first frame the
// `SelfPlayer` entity is queryable. Reading a missing field as `0` sends
// `.levelup 60` to a body that may already be 60 — which makes it **120**, above every
// bracket ceiling (`battleground_template.max_lvl = 60`), and the run then dies at
// `Greeted` blaming a level *under* the floor. A missing reading is not a zero.
// Wait for the real level: a missing one read as 0 would level a 60 to 120, above
// every bracket ceiling (`battleground_template.max_lvl = 60`).
let Some(level) = store.0.unit_level() else {
return;
};
if level < QUEUE_LEVEL {
gm(&net, format!(".levelup {}", QUEUE_LEVEL - level));
}
// **Deserter first.** Leaving a battleground any way but "the match ended" earns
// spell **26013** (vmangos `Player.cpp:2178/18816`, `Battleground.CastDeserter`
// defaults true), and `HandleBattlemasterJoinOpcode` then refuses the join outright
// (`BG_GROUPJOIN_DESERTERS`) — so the *second* run of this probe never queues at all,
// and reads as "the queue is broken" rather than "the last run left a debuff". Found
// the hard way; cleared unconditionally, because a body without it does not care.
// Clear Deserter (26013): leaving before the match ends casts it
// (`Player.cpp:2178/18816`), and the join handler then refuses the queue
// (`BG_GROUPJOIN_DESERTERS`, `BattleGroundHandler.cpp:176`).
gm(&net, ".unaura 26013");
let [x, y, z] = arena.at;
info!(
@@ -645,15 +443,9 @@ fn bg_probe(
if now - sent_at < 3.0 {
return; // post-teleport settle: let the battlemasters stream in
}
// **The lever goes here, not in Setup, and its reply is read before anything queues.**
// `.debug bg` is a TOGGLE (`BattleGroundMgr::ToggleTesting`), so a blind send is not
// idempotent and a run that died before its own undo flips the next one the wrong way.
// Correcting it *after* joining does not work either, and that is the part worth
// writing down: vmangos evaluates a queue only when something SCHEDULES an update
// (`BattleGroundMgr::Update` drains `m_queueUpdateScheduler`, which a join or a leave
// fills — `BattleGroundMgr.cpp:1005-1028`). Flipping the flag under a queue entry that
// is already sitting there schedules nothing, so the entry waits forever. Hence: get
// the flag right first, then join once.
// The lever is settled before the join: vmangos evaluates a queue only when a join or
// a leave schedules it (`BattleGroundMgr.cpp:1005-1028`), so flipping it under a
// waiting entry does nothing.
toggle_testing(&mut probe, &net);
probe.phase = Phase::Toggled {
sent_at: now,
@@ -661,18 +453,10 @@ fn bg_probe(
};
}
Phase::Toggled { sent_at, corrected } => {
// **Drain every frame and keep what comes back**, rather than taking one destructive
// look 1.5 s in. The reply is a world text that crosses the network, reaches the chat
// pipeline and then the tap; a single window wide enough to be reliable is wider than
// one worth waiting on every run, and a single window narrow enough to be quick misses
// the reply often enough to matter — a missed reply is how this probe misdiagnoses
// itself. Anything drained here that is *not* the reply is kept for the first census
// instead of being thrown on the floor.
// Drain every frame until the reply shows; the rest is kept for the first census.
if let Some(s) = script.as_deref_mut() {
let seen = drain_events(&mut probe, s);
// The LAST marker in the chunk wins, because both can appear in one drain when the
// correcting send's reply arrives alongside the first. `contains` cannot express
// that and was the reason a corrected run could read its own first reply again.
// The last marker wins: a correcting send's reply can share a drain with the first.
let on = seen.rfind("debugging");
let off = seen.rfind("normal playercount");
match (on, off) {
@@ -708,10 +492,7 @@ fn bg_probe(
};
}
None if now - sent_at > TOGGLE_REPLY_WINDOW => {
// Unread is a THIRD outcome, and it is carried forward rather than rounded to
// "on": the `Joined` failure text used to assert the flag "was confirmed ON
// before the join" even on this path, sending the reader to look at Deserter
// and brackets when the lever was the actual suspect.
// Unread stays unread: `testing_confirmed` is left false for the failure text.
warn!(
"PROBE bg: TESTING unread — no `.debug bg` reply reached the tap in \
{TOGGLE_REPLY_WINDOW:.0}s; continuing, but if the queue never pops, this \
@@ -719,13 +500,12 @@ fn bg_probe(
);
probe.phase = Phase::Scanning { since: now };
}
// Still inside the window: stay in `Toggled` and look again next frame.
None => {}
}
}
Phase::Scanning { since: sent_at } => {
let here = player.pos;
// By ENTRY, not by "the nearest battlemaster": all three stand in one alcove.
// By entry, not nearness: all three stand in one alcove.
let master = units.iter().find(|(guid, kind, store, tf)| {
kind.kind == benilla_protocol::EntityKind::Unit
&& store.0.unit_npc_flags() & npc_flags::BATTLEMASTER != 0
@@ -751,8 +531,7 @@ fn bg_probe(
}
}
Phase::Greeted { master, sent_at } => {
// Waiting for the LIST, not for a clock: its guid is the one the join must quote, and
// its arrival is the proof the level gate was cleared.
// The list's guid is the one the join quotes; its arrival proves the level gate passed.
if battlefield.battlemaster() == Some(master) {
info!("PROBE bg: LISTED — queueing for map {}", arena.map);
let _ = net.0.send(ClientCommand::BattlemasterJoin {
@@ -771,15 +550,8 @@ fn bg_probe(
}
}
Phase::Joined { sent_at } => {
// Status 2 is "your battleground is ready, confirm within the deadline". The probe
// takes it through the STOCK VERB, not through a hand-built packet: the whole point
// is that the reference's own dialog road works.
// **Matched on the MAP as well as the status**, because a slot is not ours just because
// it is ready. A killed run can leave this character queued for another battleground,
// and vmangos re-sends that `SMSG_BATTLEFIELD_STATUS` at login — so a status-2 slot for
// Arathi Basin would be taken here, port the body into map 529, and the `Ported` arm
// would then time out complaining that the map "is still 529, not 489". The cause would
// read as a broken worldport; it is a stale queue entry the probe confirmed on purpose.
// Status 2 is "ready, confirm", taken through the stock verb. Matched on the map too:
// vmangos re-sends a stale queue's status at login.
let ready = queue.slots().iter().position(|s| {
s.as_ref()
.is_some_and(|(st, _)| st.status == 2 && st.map_id == arena.map)
@@ -801,14 +573,8 @@ fn bg_probe(
}
probe.phase = Phase::Ported { sent_at: now };
} else if now - sent_at > 12.0 && probe.rejoins < MAX_REJOINS {
// **Leave the queue and join again.** vmangos evaluates a queue only when
// something SCHEDULES an update, and a queue entry that was scheduled once and
// did not match just sits there — no timer re-examines it
// (`BattleGroundMgr::Update` drains `m_queueUpdateScheduler` and nothing refills
// it on its own, `BattleGroundMgr.cpp:1005`). Whatever left the server's
// bookkeeping unable to match this entry, a fresh one gets a fresh evaluation.
// Both halves are real client verbs: `AcceptBattlefieldPort(slot, 0)` is the
// queue's own decline, which no other probe or test here has ever walked.
// Decline and re-join: an entry that did not match is never re-examined until a
// join or a leave schedules its queue (`BattleGroundMgr.cpp:1005`).
probe.rejoins += 1;
let slot = queue
.slots()
@@ -888,9 +654,7 @@ fn bg_probe(
finish(&mut probe, &net);
return;
};
// Drained HERE, not inside `census`, so the tap's self-heal can reach `probe` — and so
// everything the tap caught on the queue road is attributed to the window it actually
// happened in rather than being folded into the first census.
// Drained here, not in `census`, so the tap's self-heal can reach `probe`.
let mut events = std::mem::take(&mut probe.pending_events);
events.push_str(&drain_events(&mut probe, &mut script));
let tap_broken = probe.tap_broken;
@@ -912,18 +676,8 @@ fn bg_probe(
);
let samples = samples + 1;
// **Once inside, the lever has done its job — put it back immediately.**
//
// It was an equality test on `DOORS_SAMPLE` (11), and that was wrong twice over. Any
// `WOW_PROBE_BG_SAMPLES` below 11 — a documented, user-facing dial — skipped it
// entirely and left the realm-global flag flipped for the next run, which is the exact
// breakage 2290 §2 records as having cost runs 2, 4 and 5. And there was no reason to
// wait: the flag is needed only for `CheckNormalMatch` to *form* the match, which has
// already happened by the time we are standing in it. vmangos arms the premature
// countdown when the battleground reaches `STATUS_IN_PROGRESS` (the doors) and
// decrements it inside `else if (!sBattleGroundMgr.isTesting())` (`BattleGround.cpp:337`),
// so restoring it now simply lets that clock run from the doors rather than from
// sample 11 — twelve seconds *earlier* to an ending, not later.
// Inside, the lever has formed the match; off again so the premature-finish countdown
// runs (it only decrements while testing is off, `BattleGround.cpp:337`).
if samples == 1 {
info!(
"PROBE bg: TESTING — inside the instance; the lever has done its job and the \
@@ -933,11 +687,8 @@ fn bg_probe(
}
if samples >= census_samples() {
// **The graveyard leg.** `.die` is the one thing that clears the probe's god
// shield by design (0677), so a probe CAN die on purpose; a battleground death is
// also the only place the area-spirit-healer arc (2291) is reachable at all, and
// a mechanism that has never been watched fire is not a mechanism anyone should
// claim. Non-combat: nothing kills us, we ask to be dead.
// The flag leg, then the graveyard: `.die` clears the probe's god shield, and a
// battleground death is the only way to reach the area spirit healer.
match arena.flag {
Some((entry, [x, y, z])) => {
info!("PROBE bg: FLAG — hopping to {entry} at ({x}, {y}, {z}) to take it");
@@ -960,7 +711,7 @@ fn bg_probe(
}
Phase::Rejoining { at } => {
if now - at < 2.0 {
return; // let the decline land and the slot clear
return; // the decline lands and the slot clears
}
let Some(master) = battlefield.battlemaster() else {
error!("PROBE bg: FAILURE — the battlemaster list went away before the rejoin");
@@ -977,7 +728,7 @@ fn bg_probe(
}
Phase::FlagHop { at } => {
if now - at < 4.0 {
return; // the hop crosses the map; let the far base stream in
return; // the hop crosses the map; the far base streams in
}
let Some((entry, _)) = arena.flag else {
finish(&mut probe, &net);
@@ -990,9 +741,8 @@ fn bg_probe(
});
match found {
Some((guid, _, _, tf)) => {
// What the click's own GameObject ladder would decide, reported rather than
// assumed: a FLAGSTAND carries no lock, so `resolve_go_action` takes its
// `Use` arm and the packet is `CMSG_GAMEOBJ_USE` — the one sent here.
// A flag stand has no lock, so a click's `resolve_go_action` sends
// `CMSG_GAMEOBJ_USE`, as here; the lock is logged, not assumed.
let lock = go_templates.get(guid.0).map_or(u32::MAX, |t| t.lock_id);
info!(
"PROBE bg: FLAG {entry} streamed at {:.1} yd, lock_id={lock} — CMSG_GAMEOBJ_USE",
@@ -1021,13 +771,10 @@ fn bg_probe(
finish(&mut probe, &net);
return;
};
// **The two readings that say the flag is on the body.** World state 2339 is the
// ALLIANCE readout row's icon selector, and it is only ever 1 or 2 — 2 meaning the
// flag that row is about is being carried (vmangos `BattleGroundWS.cpp:512` and
// `FillInitialWorldStates` 713-722). It is **not** the four-value `m_flagState` enum,
// which never reaches the client at all; that distinction is why a census reading
// `2338=1 2339=1` for a whole match is correct rather than a stuck value. The aura is
// the other half: what the reference actually draws on the body.
// World state 2339 is the Alliance row's icon selector: 1, or 2 while the flag that
// row is about is carried (`BattleGroundWS.cpp:512`, `:714-722`), never the four-value
// `m_flagState`, so `2339=1` held all match is correct, not stuck. The buffs are the
// other half.
let auras = script
.eval::<String>(
r#"
@@ -1092,10 +839,8 @@ fn bg_probe(
finish(&mut probe, &net);
return;
};
// The whole point of this leg, in four readings: are we a ghost, is a spirit guide in
// range, did the engine adopt it (`GetAreaSpiritHealerTime` is non-zero only once
// `SMSG_AREA_SPIRIT_HEALER_TIME` answered the query the adopt sent), and did the
// stock dialog's event fire.
// Ghost, nearest spirit guide, the wave clock (non-zero only once
// `SMSG_AREA_SPIRIT_HEALER_TIME` answers) and the stock popup.
let ghost = store.0.player_is_ghost();
let nearest = units
.iter()
@@ -1128,8 +873,7 @@ fn bg_probe(
);
let samples = samples + 1;
if samples >= GHOST_SAMPLES {
// Take the wave if one is offered — the verb that was permanently silent before
// 2291, and the only way back to the world from a battleground graveyard.
// Take the wave if one is offered.
match script.eval::<()>("AcceptAreaSpiritHeal()") {
Ok(()) => info!("PROBE bg: GHOST AcceptAreaSpiritHeal() sent"),
Err(e) => error!("PROBE bg: GHOST AcceptAreaSpiritHeal() raised: {e}"),
@@ -1148,26 +892,10 @@ fn bg_probe(
}
}
/// **Reaching `Done` ends the process.** This probe was the one live probe here without it
/// (`probe_auction`'s tail and `ProbeExitPlugin::fire_probe_exit` are the established shape), so
/// the client simply idled at `Done` until whatever external `timeout` wrapped the run killed it:
/// a four-minute Warsong Gulch census cost a fifteen-minute wall clock, and a sequence of runs
/// spent most of its time watching a finished probe stand still.
///
/// It is its own system rather than an arm of [`bg_probe`] only because that system is already at
/// Bevy's parameter ceiling — there is nothing conditional about the split.
///
/// **It waits for the leave to land before it goes**, and that wait is the whole reason this is not
/// a one-liner. [`report`] ends by teleporting the body out to map 0, and `AppExit` stops the
/// Update schedule — so exiting on the next frame can cut the connection before the worldport
/// completes and strand the body **inside a live battleground**, which is precisely the poisoned
/// state this probe warns the next run about. So: leave first, confirmed by the map actually
/// changing, then exit; with [`LEAVE_GRACE`] as the backstop for a leave that never lands, because
/// hanging forever would be worse than logging out somewhere awkward.
///
/// The polite `AppExit` plus a hard backstop on its own OS thread is the pattern, not belt and
/// braces: `AppExit` stops the Update schedule, so an in-schedule backstop could never fire, and a
/// net/winit teardown hang would otherwise leave a zombie client holding the probe account.
/// Ends the process once [`Phase::Done`] is reached and the body has left the battleground map
/// (or [`LEAVE_GRACE`] passed): exiting before the worldport completes leaves the body inside a
/// live battleground. A separate system because [`bg_probe`] is at Bevy's parameter ceiling; the
/// hard-exit thread fires even though `AppExit` stops the Update schedule.
fn bg_probe_exit(
time: ProbeClock,
mut probe: ResMut<BgProbe>,
@@ -1200,9 +928,7 @@ fn bg_probe_exit(
});
}
/// One census sample — eleven greppable `PROBE bg:` lines describing everything the client can see
/// from inside the battleground. Deliberately verbose: this is the first look anyone has had, and
/// a column nobody needed is cheaper than a round trip for one that was left out.
/// One census sample: greppable `PROBE bg:` lines for everything the client sees from inside.
#[allow(clippy::too_many_arguments)]
fn census(
t: f64,
@@ -1233,9 +959,7 @@ fn census(
},
);
info!(
// `map=none` rather than `map=0`: 0 is Eastern Kingdoms, a real answer, and printing it
// for "there is no `CurrentMap` resource" is the same missing-reads-as-a-value mistake
// the world-state column had.
// `map=none`, not `map=0`: 0 is Eastern Kingdoms.
"PROBE bg: CENSUS t={t:.0}s map={} area={:?} pos=({:.0},{:.0},{:.0}) terrain={terrain}",
map.map_or_else(|| "none".to_string(), |m| m.0.to_string()),
area.and_then(|a| a.0),
@@ -1244,7 +968,7 @@ fn census(
p.z,
);
// Entities, by kind — "is there a world here at all?"
// Entities, by kind.
let (mut u, mut pl, mut go, mut dy, mut co) = (0, 0, 0, 0, 0);
let mut go_entries: Vec<(u32, Option<String>)> = Vec::new();
let mut nearest_guide: Option<(u64, f32)> = None;
@@ -1263,10 +987,7 @@ fn census(
K::GameObject => {
go += 1;
if let Some(e) = benilla_protocol::guid::entry(guid.0) {
// The NAME, not just the entry: "179918" is a number to look up in a database
// and `Doodad_PortcullisActive01` is the battleground's gate. The cache is the
// ask-once `GAMEOBJECT_QUERY` store (0239) the client already fills for every
// GO that streams in, so this costs nothing and answers for free.
// With its name from the `GAMEOBJECT_QUERY` cache, already filled per stream.
go_entries.push((e, go_templates.get(guid.0).map(|t| t.name.clone())));
}
}
@@ -1308,7 +1029,7 @@ fn census(
)
);
// The world-state table — the battleground's whole score, as raw dwords.
// The world-state table, the battleground's whole score, as raw dwords.
let mut pairs: Vec<(u32, i32)> = states.pairs().collect();
pairs.sort_unstable_by_key(|(k, _)| *k);
info!(
@@ -1322,7 +1043,7 @@ fn census(
.join(" ")
);
// What the reference's own readout would draw, straight out of the VM.
// What the stock readout would draw, out of the VM.
let alwaysup = script
.eval::<String>(
r#"
@@ -1365,11 +1086,8 @@ fn census(
.unwrap_or_else(|e| format!("<raised: {e}>"));
info!("PROBE bg: SCORE n={score}");
// **The wire-coverage tally** — every opcode the codec threw on the floor, by name. This is
// the column the first run did not have and most needed: a battleground that sends something
// benilla has no arm for is invisible in every other reading here, and reads as "nothing
// happened" rather than as a gap. `DroppedOpcodes` is never cleared, so the numbers are
// cumulative over the whole session — a row that grows between samples is the finding.
// Every opcode the codec dropped, by name; `DroppedOpcodes` is never cleared, so the counts
// are cumulative over the session.
let mut drops: Vec<(u16, u64, u64)> = dropped
.0
.iter()
@@ -1393,11 +1111,7 @@ fn census(
}
);
// The event tap's window (see `EVENT_TAP`): what the VM was actually told since the last
// sample. An empty window while the server is running the start countdown is a finding —
// **provided the tap is alive**, which is why a broken tap says so instead of printing
// `(none)`. "The battleground fired nothing" and "nobody was listening" are opposite
// conclusions and used to produce identical lines.
// The event tap's window since the last sample; a broken tap says so instead of `(none)`.
info!(
"PROBE bg: EVENTS{}",
if tap_broken {
@@ -1409,34 +1123,16 @@ fn census(
}
);
// **No `LUA` column, deliberately.** It used to print `LUA clean` off `take_errors()`, which
// reads as "the interface raised nothing inside a battleground for the last 12 s". It cannot
// mean that: `take_errors` is a DRAIN, and two systems already drain it every frame
// (`ui_script::input` and `ui_script::extract::extract`), so this call could only ever see
// whatever our own `eval`s in this same frame had just raised. A column that reports a 12 s
// window while measuring a fraction of one frame is worse than no column, because it reads as
// evidence. Engine-side Lua raises appear as `WARN ui_script:` lines — grep for those.
// No Lua-error column: `take_errors` is drained every frame by `ui_script::input` and
// `ui_script::extract`, so Lua raises show as `WARN ui_script:` lines instead.
let _ = (arena, script);
}
/// The run's last act: drive the two battleground windows the census cannot reach by watching
/// (they are opened by a keybind, never by an event), then put the server's testing toggle back
/// and leave the battleground the way a player does.
/// The run's last act: read the battle map, toggle the scoreboard and the battlefield minimap
/// (opened by a keybind, never by an event), then leave the battleground.
fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &mut UiScript) {
// The scoreboard and the battlefield minimap: both are LoadOnDemand-shaped roads nothing in
// this tree has ever walked. A raise here is the finding.
// **Read both windows BEFORE toggling them.** The scoreboard shows itself when a match ends —
// `WorldStateScoreFrame_Update` does `if (GetBattlefieldWinner()) then ShowUIPanel(...)`,
// driven by `UPDATE_BATTLEFIELD_SCORE` — so after a real ending the toggle below *hides* it,
// and reading only the post-toggle state reports `IsShown=nil` for the run where the feature
// worked. That is the wrong way round, and it cost a round of inference to notice.
//
// Through `getglobal`, because `BattlefieldMinimap` is LoadOnDemand: indexing it before the
// toggle that loads it is a nil-index raise, and "not loaded yet" is a reading, not an error.
// **Does the battle map have anything to draw?** `IsShown()` alone cannot say: the stock
// `BattlefieldMinimap_Update` bails on its fourth line when `GetMapInfo()` is nil, so a window
// that is up and completely empty reports exactly the same `IsShown=1` as one full of terrain.
// These four readings are the difference, and they are the ones the minimap round never took.
// Whether the battle map has anything to draw: `BattlefieldMinimap_Update` returns early when
// `GetMapInfo()` is nil (`Blizzard_BattlefieldMinimap.lua:84`), which `IsShown()` cannot see.
let mapinfo = script
.eval::<String>(
r#"
@@ -1451,6 +1147,9 @@ fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &m
.unwrap_or_else(|e| format!("<raised: {e}>"));
info!("PROBE bg: MAPINFO {mapinfo}");
// Read both before toggling: `WorldStateScoreFrame_Update` shows the scoreboard once there is
// a winner (`WorldStateFrame.lua:192-194`), so the toggle would hide it. Through `getglobal`
// because `BattlefieldMinimap` is load-on-demand.
for name in ["WorldStateScoreFrame", "BattlefieldMinimap"] {
let shown = script
.eval::<String>(&format!(
@@ -1484,24 +1183,16 @@ fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &m
error!("PROBE bg: LUA ERROR {e}");
}
// `LeaveBattlefield` is gated client-side on the scoreboard's "ended" byte (1972), so while a
// match is live the verb is correctly silent — which means the honest way out is the same one
// a player uses when they give up on a battleground: the GM teleport home. The queue slot the
// server still holds is cleared by the leave it sends on the map change.
// `LeaveBattlefield` sends nothing until the scoreboard's "ended" byte (`0x4abe66`), so a live
// match is left by a GM teleport; the server clears the slot on the map change.
info!(
"PROBE bg: LEAVING map {} (active_map={:?})",
arena.map,
queue.active_map()
);
let _ = script.eval::<()>("LeaveBattlefield()");
// **NOT `.recall`.** vmangos saves the recall point on *every* `.go`
// (`HandleGoHelper` → `SaveRecallPosition()`, `TeleportCommands.cpp:755`), so the flag leg's
// `.go xyz … 489` overwrote it with a position **inside the battleground**. `.recall` then
// resolves to the same map and `Player::TeleportTo` takes its near-teleport arm — the probe
// printed `LEAVING map 489` and went nowhere, leaving the body logged out inside a live
// instance and the queue slot uncleared. That is the poisoned state the `Joined` failure text
// warns the *next* run about, manufactured by this one. An explicit hop to the battlemaster
// alcove on map 0 is a real map change, which is what clears the slot.
// Not `.recall`: every `.go` saves the recall point (`TeleportCommands.cpp:755`), so after
// the flag leg it lies inside the battleground. A hop to map 0 is a real map change.
let [x, y, z] = arena.at;
gm(net, format!(".go xyz {x} {y} {z} 0"));
info!("PROBE bg: DONE");
@@ -1,37 +1,17 @@
//! The battleground-queue live probe (`WOW_PROBE_BGQUEUE=1`) — the instrument that makes
//! "**log in while queued for a battleground**" a shape anyone can reproduce, inert without the
//! env.
//! The battleground-queue live probe (`WOW_PROBE_BGQUEUE=1`): the fixture for a login while
//! queued. It levels the body past the bracket floor, hops to the Stormwind Warsong Gulch
//! battlemaster, greets him (`CMSG_BATTLEMASTER_HELLO`, `SMSG_BATTLEFIELD_LIST`), joins through
//! the listed guid (`CMSG_BATTLEMASTER_JOIN`) and logs `PROBE bgqueue:` with the slot it got.
//!
//! Decision 2232 audited the 29 drains that could still lose a login burst and argued this one
//! safe on paper: `SMSG_BATTLEFIELD_STATUS` is published as `UPDATE_BATTLEFIELD_STATUS` off a
//! `mem::take` latch that no `VmMemo` re-arms, so a message consumed in 2214's one-frame window
//! is gone — but [`crate::ui_battlefield::reset_on_world_enter`] re-sends
//! `CMSG_BATTLEFIELD_STATUS` on entry and the server answers slot by slot, so the queue heals
//! itself. That record named the argument **unexercised**, and the reason it stayed unexercised
//! is that the setup is three server-side gates deep, not that nobody thought to look.
//! The body stays queued: vmangos only marks a logged-out entry offline
//! (`BattleGroundMgr.cpp:1768`) and sends its status again at login
//! (`BattleGroundMgr.cpp:1728`), so the next plain login is the measurement. Dequeue it with
//! `WOW_PROBE_LUA='AcceptBattlefieldPort(1,0)'` on a plain login.
//!
//! So: level past the bracket floor, GM-hop to the Stormwind Warsong Gulch battlemaster, greet
//! him on the real wire (`CMSG_BATTLEMASTER_HELLO` → `SMSG_BATTLEFIELD_LIST`), queue through the
//! guid that list carried (`CMSG_BATTLEMASTER_JOIN`), and report the slot the server put us in.
//! The probe then **leaves the body queued** — vmangos keeps the queue entry across a logout
//! (`BattleGroundQueue::PlayerLoggedOut` only marks it offline) and re-sends the status from
//! `Player::LoadFromDB`, i.e. inside the login burst. The NEXT plain login is the measurement;
//! this run is its fixture.
//! The bodyless `CMSG_BATTLEFIELD_JOIN` cannot queue: vmangos passes an empty battlemaster guid
//! (`BattleGroundHandler.cpp:78`), which the NPC interaction check never resolves.
//!
//! **Why the guid and not `CMSG_BATTLEFIELD_JOIN`.** vmangos routes the bodyless join through
//! `RequestBgJoinQueue(ObjectGuid{}, …)`, whose `GetNPCIfCanInteractWith(battlemaster,
//! UNIT_NPC_FLAG_BATTLEMASTER)` cannot resolve an empty guid — it logs an anticheat line and
//! returns, silently. Only the battlemaster leg queues anyone, which is why this probe has to
//! stand next to one.
//!
//! **The undo is one line**, and it belongs here rather than in whatever record cites the probe:
//! the body stays queued until something dequeues it, so leave it clean with
//! `WOW_PROBE_LUA='AcceptBattlefieldPort(1,0)'` on a plain login — `GetBattlefieldStatus(1)` then
//! answers `none`. A body left queued is not harmful, only surprising: every later login on that
//! account carries an extra `SMSG_BATTLEFIELD_STATUS` in its burst.
//!
//! Non-combat. Pair with the 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.
//! Non-combat; the switches are `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::prelude::*;
@@ -42,22 +22,15 @@ use crate::target::cursor_mode::npc_flags;
use crate::ui_battlefield::Battlefield;
use crate::ui_dialog_verbs::BattlefieldQueue;
/// Elfarran's spawn (vmangos `creature` guid 54614, entry 14981, map 0) — Stormwind's Warsong
/// Gulch battlemaster, `battlemaster_entry` row `14981 → bg_template 2`. The `.go xyz` target;
/// the battlemaster himself is then found in the streamed world by his npc flag, never by a guid.
/// Elfarran's spawn (vmangos `creature` guid 54614, entry 14981, map 0), Stormwind's Warsong
/// Gulch battlemaster; he is then found in the streamed world by his npc flag.
const BATTLEMASTER_AT: [f32; 3] = [-8454.62, 318.85, 120.97];
/// Warsong Gulch's Map.dbc row — what `GetBattleGroundTypeIdByMapId` turns back into template 2.
/// Warsong Gulch's Map.dbc row, which `GetBattleGroundTypeIdByMapId` maps to template 2.
const WSG_MAP: u32 = 489;
/// The level the probe body is raised to before queueing.
///
/// `battleground_template` carries one row per content patch, and the server picks the highest at
/// or below its own: WSG opens at **10** on patch 6, **20** on patch 5 and **21** on patch 3. 25
/// clears all three, so the probe never has to know which patch the local server is running —
/// a wrong guess there fails as a bare `LANG_YOUR_BG_LEVEL_REQ_ERROR` notification with no queue,
/// which reads exactly like a broken client.
/// The level the body is raised to: `battleground_template` opens WSG at 10, 20 or 21 by content
/// patch, and 25 clears all three.
const QUEUE_LEVEL: u32 = 25;
/// `UNIT_NPC_FLAG_BATTLEMASTER`'s service range is the ordinary NPC one; 15 yd is the hop's
/// settle radius, not a gate — the server applies its own interaction check.
/// The hop's search radius, not a gate: the server applies its own interaction range.
const NEAR_YD: f32 = 15.0;
pub(crate) struct ProbeBgQueuePlugin;
@@ -74,8 +47,7 @@ struct BgQueueProbe {
phase: Phase,
}
/// `Wait` → (levelled + GM hop sent) `Hopped` → (battlemaster found, hello sent) `Greeted` →
/// (the list landed, join sent) `Joined` → verdict → `Done`.
/// `Wait`, `Hopped` (levelled, hop sent), `Greeted` (hello sent), `Joined` (join sent), `Done`.
#[derive(Default, PartialEq)]
enum Phase {
#[default]
@@ -93,7 +65,6 @@ enum Phase {
Done,
}
// One Bevy system's full input set (the guard-poi probe's shape).
fn bg_queue_probe(
time: ProbeClock,
mut probe: ResMut<BgQueueProbe>,
@@ -110,18 +81,14 @@ fn bg_queue_probe(
let now = time.elapsed_secs_f64();
match probe.phase {
Phase::Wait => {
// **Revive first, unconditionally.** `CanInteractWithNPC` refuses a dead player
// outright (`!IsAlive() && !VISIBLE_TO_GHOSTS`), and the refusal surfaces as an
// anticheat line about an "invalid creature" — which reads like a wrong guid, not
// like a corpse. A probe body parks where the last run left it, and this one is
// easy to leave drowned; `.revive` on a living character is a no-op.
// Revive first: `CanInteractWithNPC` refuses a dead player (vmangos `Player.cpp:2529`)
// with a log line that reads like a wrong guid; `.revive` on the living is a no-op.
let _ = net.0.send(ClientCommand::Chat {
kind: crate::net::ChatKind::Say,
target: None,
text: ".revive".to_string(),
});
// The level next: a body under the bracket floor is refused at the HELLO, before the
// list is ever built, so there would be nothing to queue through.
// A body under the bracket floor is refused at the hello, with no list to join from.
let level = store.0.unit_level().unwrap_or(0);
if level < QUEUE_LEVEL {
info!("PROBE bgqueue: level {level} — raising to {QUEUE_LEVEL} for the bracket");
@@ -142,7 +109,7 @@ fn bg_queue_probe(
}
Phase::Hopped { sent_at } => {
if now - sent_at < 3.0 {
return; // post-teleport settle: let the battlemaster stream in
return; // let the battlemaster stream in
}
let here = player.pos;
let master = units.iter().find(|(_, net_e, store, tf)| {
@@ -166,8 +133,7 @@ fn bg_queue_probe(
}
}
Phase::Greeted { master, sent_at } => {
// Waiting for the LIST, not for a clock: the guid it carries is the one the join has
// to quote, and its arrival is the proof the level gate was cleared.
// Wait for the list: the join quotes its guid, and it proves the level gate cleared.
if battlefield.battlemaster() == Some(master) {
info!("PROBE bgqueue: list landed — queueing for map {WSG_MAP}");
let _ = net.0.send(ClientCommand::BattlemasterJoin {
+60 -158
View File
@@ -1,88 +1,19 @@
//! The innkeeper-bind live probe (`WOW_PROBE_BINDER=1`) — decision 1331's end-to-end instrument and
//! the evidence that closes **B249** ("setting your Hearthstone shows a wrong icon and doesn't
//! take"): log in, GM-hop to the exact innkeeper in the bug's screenshot, open her gossip menu on
//! the real wire, assert the bind row's icon reads **binder** (half 1), select it, assert the
//! server's `SMSG_BINDER_CONFIRM` arrived and reached the Lua dialog (half 2's question), answer it
//! through the reference's own `ConfirmBinder()`, and assert the hearthstone actually moved (half
//! 2's answer). One `PROBE_BINDER: <step> PASS/FAIL/SKIP <detail>` line per step, then a final
//! `PROBE_BINDER: DONE pass=<n> fail=<m>`. Modeled closely on [`super::probe_bank`] — same phase
//! machine, same trace style, same self-terminating exit ([`super::probes::ProbeExitPlugin`]'s
//! pattern), same live-VM observation idiom (a small Lua hook appending to a probe table, read back
//! with `script.eval`).
//! The innkeeper-bind live probe (`WOW_PROBE_BINDER=1`): hops to Innkeeper Keldamyr (entry 6736,
//! Dolanaar, map 1), opens her gossip on the real wire, checks the bind row's icon maps to
//! `binder`, selects it, checks `SMSG_BINDER_CONFIRM` reaches `CONFIRM_BINDER`, answers with
//! `ConfirmBinder()`, and checks the hearthstone moved and the bind announced itself. One
//! `PROBE_BINDER: <step> PASS/FAIL/SKIP <detail>` line per step, then `PROBE_BINDER: DONE
//! pass=<n> fail=<m>`, and it exits. An environmental problem SKIPs; a wrong value FAILs.
//! Non-combat; the switches are `docs/CONTRIBUTING.md`, "Running it unattended".
//!
//! Unit tests cannot close B249. Half 1's old mapping was self-consistent (it just described a
//! *later* client's icon art), and half 2's packet was never parsed at all — both defects are only
//! visible against a real server sending real bytes, which is what this probe puts in front of
//! them.
//!
//! ## 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)`,
//! `creature_template.npc_flags = 135` = `0x87` = GOSSIP|QUESTGIVER|VENDOR|**INNKEEPER**,
//! `gossip_menu_id = 1293`. She is the NPC in the bug's screenshot, which is why the probe hops to
//! her rather than to whichever innkeeper is nearest.
//!
//! **`UNIT_NPC_FLAG_INNKEEPER` is `0x80` (128) on 1.12** — vmangos `Objects/UnitDefines.h:610`,
//! read this session. `GossipDef.h:45`'s comment beside `GOSSIP_OPTION_INNKEEPER` says `(65536)`,
//! which is a *later* client's value for the same flag: exactly the stale-comment trap decision
//! 1331 is about (half 1 was a hand-written icon map that trusted that same header's enum *names*).
//! [`NPC_FLAG_INNKEEPER`] below is the verified `0x80`, and it is only the scan's fallback — the
//! primary identity check is the template entry.
//!
//! ## The bind row (live-DB verified this session)
//!
//! `gossip_menu_option` for menu 1293 carries five rows; the innkeeper's is
//! `option_icon = 5`, `option_id = 8` (`GOSSIP_OPTION_INNKEEPER`), `npc_option_npcflag = 128`,
//! text *"Make this inn my home."* — and **every one of the 21 `option_id = 8` rows in the whole
//! world DB uses `option_icon = 5`** (`SELECT option_icon, COUNT(*) … WHERE option_id = 8 GROUP BY
//! option_icon` → a single row, `5 | 21`). So icon 5 is *the* innkeeper icon, and 5 was precisely
//! the byte the pre-1331 map had no entry for: it fell through to the chat bubble. Step 3 FAILs
//! loudly on `"gossip"` for that reason — that string is the B249 regression itself.
//!
//! Two things a live reading shows that the DB row does not, both verified on the first run:
//!
//! - **The label on the wire is not `option_text`.** vmangos prefers the row's
//! `option_broadcast_text` (2822 -> *"Make this inn your home."*) over the `option_text` column
//! (*"Make this inn **my** home."*). Step 4's guard is therefore a lowercase `"home"` substring,
//! not an equality — the exact wording is the server's to choose.
//! - **A probe login sees five rows here, not the three in the bug's screenshot.** GM mode is the
//! probe default (decision 0679; the preflight banner says so every run), and vmangos does not
//! condition-filter a GM's menu — it appends `"(GM mode is ON)"` to the two holiday rows and
//! sends them anyway. So the bind row sits at list position 2 with wire index 1 on a probe, and
//! would sit at position 1 on a player. That is exactly why step 3 finds the row by its **wire
//! icon byte** and step 4 selects by its **wire index**, and why neither ever counts positions.
//!
//! ## What each step can and cannot conclude
//!
//! Step 5 is the load-bearing one. Before 1331 `SMSG_BINDER_CONFIRM` (0x2eb) had no const, no parse
//! arm and no dialog: it fell through `parse.rs`'s tail into `ServerPacket::Other` and the click
//! produced a closing gossip window and nothing else. A PASS there means the packet is parsed, the
//! session state took it, and the `CONFIRM_BINDER` event reached the live VM carrying a real area
//! name.
//!
//! Step 6 answers it the way a player does — `ConfirmBinder()` run in the live VM, so the whole
//! dialog→engine→drain→wire chain (`CMSG_BINDER_ACTIVATE`, 0x1b5) is exercised rather than a
//! synthesized packet. **A rebind to the place you are already bound is invisible unless you make
//! it visible**: vmangos's `EffectBind` (`SpellEffects.cpp`) writes the homebind and sends
//! `SMSG_BINDPOINTUPDATE` *unconditionally*, but our [`HomeBind`] would simply be re-set to the
//! value it already held. So the probe clears `HomeBind` to `None` immediately before answering
//! and waits for it to become `Some` again — that transition is the fresh packet, and it is the
//! only reading that survives a probe character who already hearths in Dolanaar from an earlier
//! run.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_DATA=WoW/Data WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
//! WOW_PROBE_BINDER=1 cargo run -q -p benilla
//! ```
//! (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
//! streamed, a GM command refused, no UI VM in this build). A genuine wrong value is a FAIL.
//! - `UNIT_NPC_FLAG_INNKEEPER` is `0x80` (vmangos `UnitDefines.h:664`); the `(65536)` beside
//! `GOSSIP_OPTION_INNKEEPER` in `GossipDef.h:45` is a later client's value.
//! - Every `GOSSIP_OPTION_INNKEEPER` row in the world DB sends icon 5.
//! - The wire label is the row's broadcast text ("Make this inn your home."), not `option_text`.
//! - vmangos does not condition-filter a GM's menu, so a probe in GM mode sees extra rows: the
//! row is found by its wire icon and selected by its wire index, never by position.
//! - `EffectBind` sends `SMSG_BINDPOINTUPDATE` even for a rebind to the same place
//! (`SpellEffects.cpp:5806`), so the probe clears `HomeBind` first and waits for it to return.
use bevy::prelude::*;
@@ -98,45 +29,36 @@ use crate::ui_binder::BinderState;
use crate::ui_gossip::GossipState;
use crate::ui_session::NpcSession;
/// Innkeeper Keldamyr's spawn (vmangos `creature` guid 46343, entry 6736) — the `.go xyz` target.
/// Innkeeper Keldamyr's spawn (vmangos `creature` guid 46343), the `.go xyz` target.
const INNKEEPER_AT: [f32; 3] = [9802.21, 982.608, 1313.98];
/// Her map — Teldrassil. `.go xyz` takes the map id as its fourth argument.
/// Her map, Kalimdor; `.go xyz` takes the map id as its fourth argument.
const INNKEEPER_MAP: u32 = 1;
/// Her creature template entry — the streamed-unit identity check (module doc).
/// Her creature template entry, the primary identity check.
const INNKEEPER_ENTRY: u32 = 6736;
/// `UNIT_NPC_FLAG_INNKEEPER` — `0x80` on 1.12, NOT the `65536` `GossipDef.h`'s comment names
/// (module doc). The scan's fallback when the entry read is unavailable.
/// `UNIT_NPC_FLAG_INNKEEPER` on 1.12, the scan's fallback identity check.
const NPC_FLAG_INNKEEPER: u32 = 0x80;
/// The wire `GOSSIP_ICON` byte every `GOSSIP_OPTION_INNKEEPER` row in the world DB sends
/// (21/21, verified this session) — decision 1331's table indexes it to `"binder"`.
/// The wire `GOSSIP_ICON` byte every `GOSSIP_OPTION_INNKEEPER` row sends.
const ICON_INNKEEPER: u8 = 5;
/// The type string [`crate::ui_gossip`]'s table must produce for [`ICON_INNKEEPER`].
const ICON_TYPE_BINDER: &str = "binder";
/// What the pre-1331 map produced instead — the chat bubble. Seeing it back is B249, not a flake.
/// The chat bubble, what an unmapped icon byte falls back to.
const ICON_TYPE_REGRESSION: &str = "gossip";
/// The substring the bind row's label must carry before the probe is willing to select it — a
/// guard against binding whatever else the menu happens to offer if the DB row ever moves.
/// A substring the bind row's label must carry before the probe selects it.
const BIND_LABEL_HINT: &str = "home";
/// Scan radius around the `.go` landing, generously wide so a slightly-off hop still finds her
/// (the bank/mail probes' idiom).
/// Scan radius around the `.go` landing, wide enough for a slightly-off hop.
const SCAN_RANGE: f32 = 12.0;
const SETTLE_SECS: f64 = 3.0;
/// The waits are deliberately generous, and the reason is measured rather than guessed: the `.go`
/// lands on a **different map**, so the whole leg after the hop runs inside Teldrassil's terrain
/// load — the first live run logged five consecutive `frame hitch: ~1050 ms` lines and a 5.0 s
/// loading screen across steps 4→5, i.e. the probe got roughly one frame per second to poll in.
/// The packets were prompt; the *observer* was starved. A timeout tight enough to trip on that
/// would report a FAIL about the wire, which is the one thing an instrument must never do.
/// Generous: the hop lands on another map, and its terrain load leaves the probe about one frame
/// a second to poll in, though the packets arrive promptly.
const SCAN_TIMEOUT_SECS: f64 = 20.0;
const MENU_TIMEOUT_SECS: f64 = 20.0;
const CONFIRM_TIMEOUT_SECS: f64 = 20.0;
const BIND_TIMEOUT_SECS: f64 = 20.0;
const LINE_TIMEOUT_SECS: f64 = 20.0;
/// `ERR_DEATHBIND_SUCCESS_S` (GlobalStrings.lua:1543, verbatim) — what step 7 expects to read back
/// off CHAT_MSG_SYSTEM, composed here rather than imported so the probe asserts against the
/// reference's own string and not against whatever `ui_binder` happens to hold.
/// `ERR_DEATHBIND_SUCCESS_S` (`GlobalStrings.lua:1544`), written out so step 7 asserts against
/// the reference string, not `ui_binder`'s.
fn bound_line(area_name: &str) -> String {
"%s is now your home.".replace("%s", area_name)
}
@@ -150,27 +72,22 @@ impl Plugin for ProbeBinderPlugin {
}
}
/// The probe's phase machine plus the identities discovered along the way (the bank probe's shape:
/// a `Copy` phase snapshotted out of the resource each tick, so an arm can mutate `probe` freely).
/// The probe's phase machine plus the identities found along the way.
#[derive(Resource, Default)]
struct BinderProbe {
phase: Phase,
/// The innkeeper's guid, once streamed in.
innkeeper: Option<u64>,
/// The bind row's **wire** `index` — the value the packet carried and the value
/// `CMSG_GOSSIP_SELECT_OPTION` must echo back. vmangos numbers them `data << uint32(iI)` over
/// the rows it actually sends (`GossipDef.cpp:188`), so it is the row's **0-based** position in
/// *this* menu — neither the DB's `gossip_menu_option.id` nor the Lua menu's 1-based position,
/// and the probe assumes no relation between them for the same reason the real drain doesn't.
/// The bind row's wire index, echoed by `CMSG_GOSSIP_SELECT_OPTION`: its 0-based position
/// among the rows sent (`GossipDef.cpp:188`), neither the DB id nor the Lua position.
bind_index: Option<u32>,
/// The area id [`HomeBind`] held before step 6 cleared it — reported in the verdict so a
/// rebind-to-the-same-place reads as the deliberate no-visible-change case it is.
/// The area id [`HomeBind`] held before step 6 cleared it, reported in the verdict.
baseline_area: Option<u32>,
/// The area name the bind resolved to — step 7 composes its expected line from it.
/// The area name the bind resolved to; step 7 composes its expected line from it.
bound_name: String,
passes: u32,
fails: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
/// Latched once [`Phase::Done`] has fired its exit.
exited: bool,
}
@@ -182,7 +99,7 @@ enum Phase {
Settling {
sent_at: f64,
},
/// `GossipHello` sent; waiting for the parsed menu AND its push into the VM (step 2).
/// `GossipHello` sent; waiting for the parsed menu and its push into the VM (step 2).
Menu {
sent_at: f64,
},
@@ -198,23 +115,21 @@ enum Phase {
since: f64,
sent: bool,
},
/// Step 7 — the bind landed; waiting for `SMSG_PLAYERBOUND`'s
/// `ERR_DEATHBIND_SUCCESS_S` line to reach the VM as CHAT_MSG_SYSTEM.
/// Step 7: waiting for `SMSG_PLAYERBOUND`'s `ERR_DEATHBIND_SUCCESS_S` as `CHAT_MSG_SYSTEM`.
Line {
since: f64,
},
Done,
}
/// Read the Lua-side `ProbeBinderEvents` log length (the `CONFIRM_BINDER` hook) — `0` on any eval
/// hiccup, treated as "nothing observed yet", never a panic (the bank probe's idiom).
/// The Lua-side `ProbeBinderEvents` log length (the `CONFIRM_BINDER` hook); 0 on an eval error.
fn events_len(script: &UiScript) -> i64 {
script
.eval::<i64>("return table.getn(ProbeBinderEvents or {})")
.unwrap_or(0)
}
/// The newest `ProbeBinderEvents` entry — `CONFIRM_BINDER`'s `arg1`, the area name that fills the
/// The newest `ProbeBinderEvents` entry: `CONFIRM_BINDER`'s `arg1`, the area name that fills the
/// dialog's `"Do you want to make %s your new home?"`.
fn last_event(script: &UiScript) -> String {
script
@@ -222,27 +137,24 @@ fn last_event(script: &UiScript) -> String {
.unwrap_or_default()
}
/// 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; decision 1335).
/// The `CHAT_MSG_SYSTEM` lines seen since the hook went in, newest last. `SMSG_PLAYERBOUND`
/// prints `ERR_DEATHBIND_SUCCESS_S` there (`0x5e3d3f` → `DisplayError(0x138)`, chat type 238).
fn system_lines(script: &UiScript) -> Vec<String> {
script
.eval::<Vec<String>>("return ProbeBinderSystemLines or {}")
.unwrap_or_default()
}
/// How many values the live `GetGossipOptions()` returns — flat `(label, type)` pairs, so twice the
/// row count. The probe waits on this rather than assuming the feed already ran this frame.
/// How many values the live `GetGossipOptions()` returns: flat `(label, type)` pairs, twice the
/// row count.
fn vm_gossip_values(script: &UiScript) -> i64 {
script
.eval::<i64>("local t = { GetGossipOptions() } return table.getn(t)")
.unwrap_or(0)
}
/// The icon **type string** the app mapped for the 1-based menu row `pos` — read exactly where the
/// FrameXML reads it, out of the pushed [`benilla_ui::script::GossipMenu`] snapshot through the
/// Era `GetGossipOptions()` vararg. (`ui_gossip::gossip_icon_type` is private to its module; the
/// snapshot is the same value the real menu draws from, which is the stronger assert anyway.)
/// The icon type string `GetGossipOptions()` returns for the 1-based menu row `pos`, read where
/// `GossipFrame.lua` reads it.
fn vm_icon_type(script: &UiScript, pos: usize) -> String {
script
.eval::<String>(&format!(
@@ -252,15 +164,15 @@ fn vm_icon_type(script: &UiScript, pos: usize) -> String {
.unwrap_or_default()
}
/// The texture path `BENILLA_GOSSIP_ICONS.binder` resolves to in the live VM — `""` if the table or
/// the key is missing, which would mean the row draws the fallback bubble whatever the app mapped.
/// `BENILLA_GOSSIP_ICONS.binder` in the live VM, `""` when absent. The stock
/// `GossipFrame.lua:123` builds the icon path from the type string and defines no such table.
fn vm_binder_texture(script: &UiScript) -> String {
script
.eval::<String>("return (BENILLA_GOSSIP_ICONS and BENILLA_GOSSIP_ICONS.binder) or \"\"")
.unwrap_or_default()
}
/// The live VM's `GetBindLocation()` — the hearthstone's own answer for where you are bound.
/// The live VM's `GetBindLocation()`, the hearthstone's own answer.
fn vm_bind_location(script: &UiScript) -> String {
script
.eval::<String>("return GetBindLocation()")
@@ -285,15 +197,14 @@ fn binder_probe(
return; // not in-world yet
}
let Some(script) = script else {
return; // no UI VM this build (headless net-only) — nothing this probe can drive
return; // no UI VM in this build, so nothing to drive
};
let now = time.elapsed_secs_f64();
let phase = probe.phase;
match phase {
Phase::Wait => {
// The CONFIRM_BINDER hook (step 5's observation channel — the bank probe's exact
// pattern), installed up front so it is live long before the question can arrive.
// The `CONFIRM_BINDER` and `CHAT_MSG_SYSTEM` hook, live before the question can arrive.
if let Err(e) = script.run(
r#"
if not ProbeBinderHooked then
@@ -418,8 +329,8 @@ fn binder_probe(
error!(
"PROBE_BINDER: FAIL (5 confirm) — no answered question within \
{CONFIRM_TIMEOUT_SECS}s of the select: BinderState pending={:?} (wanted \
{innkeeper:#x}), CONFIRM_BINDER fired={fired}, arg1={area:?}. Before decision \
1331 the packet had no parse arm at all and fell through to \
{innkeeper:#x}), CONFIRM_BINDER fired={fired}, arg1={area:?}. A packet with \
no parse arm at all falls through to \
ServerPacket::Other — that is what this reading looks like.",
binder.npc()
);
@@ -429,9 +340,8 @@ fn binder_probe(
}
Phase::Accept { since, sent } => {
if !sent {
// Clear the bind BEFORE answering: a rebind to the area we already hearth in
// re-sends SMSG_BINDPOINTUPDATE with an identical payload, so only the
// None → Some transition proves a fresh packet landed (module doc).
// Clear the bind before answering: a rebind to the same area re-sends an
// identical `SMSG_BINDPOINTUPDATE`, so only None → Some proves a fresh packet.
probe.baseline_area = home.0;
home.0 = None;
if let Err(e) = script.run("ConfirmBinder()") {
@@ -480,9 +390,7 @@ fn binder_probe(
}
}
Phase::Line { since } => {
// The feedback half of B249: "accepting appears to change nothing" was partly that
// nothing ever SAID it had. SMSG_PLAYERBOUND prints ERR_DEATHBIND_SUCCESS_S
// ("%s is now your home.") as CHAT_MSG_SYSTEM — VERIFIED at `0x5e3d3f`, decision 1335.
// `SMSG_PLAYERBOUND` prints `ERR_DEATHBIND_SUCCESS_S` as a system line (`0x5e3d3f`).
let want = bound_line(&probe.bound_name);
let lines = system_lines(&script);
if lines.iter().any(|l| l == &want) {
@@ -507,9 +415,8 @@ fn binder_probe(
"PROBE_BINDER: DONE pass={} fail={}",
probe.passes, probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
// teardown hang cannot leave a client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
@@ -520,10 +427,7 @@ fn binder_probe(
}
}
/// Steps 3 and 4 — B249's first half, then the click.
///
/// Split out of the phase match because it is one straight line of asserts with several exits, and
/// inlining it buried the phase machine's shape. Sets `probe.phase` on every path.
/// Steps 3 and 4: the bind row's icon, then the click. Sets `probe.phase` on every path.
fn assert_icon_and_select(
probe: &mut BinderProbe,
gossip: &GossipState,
@@ -532,8 +436,7 @@ fn assert_icon_and_select(
innkeeper: u64,
now: f64,
) {
// Step 3 — the icon. The row is found by the WIRE byte, because that byte is the fact under
// test: every GOSSIP_OPTION_INNKEEPER row in the world DB sends 5 (module doc).
// Step 3, the icon: the row is found by its wire byte, the fact under test.
let Some((pos, opt)) = gossip
.options
.iter()
@@ -555,8 +458,8 @@ fn assert_icon_and_select(
if ty == ICON_TYPE_REGRESSION {
error!(
"PROBE_BINDER: FAIL (3 icon) — the innkeeper's row {:?} (wire icon={ICON_INNKEEPER}) \
maps to {ICON_TYPE_REGRESSION:?}, the chat bubble. THIS IS THE B249 REGRESSION: \
decision 1331's table must index byte 5 to {ICON_TYPE_BINDER:?}.",
maps to {ICON_TYPE_REGRESSION:?}, the chat bubble. THIS IS THE ICON REGRESSION: \
`ui_gossip`'s icon table must index byte 5 to {ICON_TYPE_BINDER:?}.",
opt.message
);
probe.fails += 1;
@@ -592,8 +495,7 @@ fn assert_icon_and_select(
);
probe.passes += 1;
// Step 4 — the click. Guarded on the label, and sent with the row's WIRE index — read off the
// packet, never derived from where the row sits in the list (the drain's own rule).
// Step 4, the click: guarded on the label, sent with the row's wire index from the packet.
if !opt.message.to_lowercase().contains(BIND_LABEL_HINT) {
warn!(
"PROBE_BINDER: SKIP (4 select) — the icon=={ICON_INNKEEPER} row reads {:?}, which does \
+33 -67
View File
@@ -1,28 +1,12 @@
//! 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?
//! The world-book live probe (`WOW_PROBE_BOOK=1`): the per-frame cost of the item-text reader
//! open on a real plaque. It hops to the plaque, samples the UI pass's meter
//! ([`crate::ui_script::UiFrameCost`]) with the reader closed, opens it, samples again, checks the
//! page's render, and logs both windows under `PROBE_BOOK:`, with how many frames the extract
//! gate skipped.
//!
//! 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
//! of each window's frames the extract gate (decision 0740) *skipped*, which is what decides
//! whether a paint-pass cost is paid once or every frame.
//!
//! 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 the reported standing position, off the debug panel: `-8760.2 402.3 103.9`.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_BOOK=1 cargo run -q -p benilla --release
//! ```
//! (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.
//! The object is Stormwind Old Town's Alliance Military Ranks plaque (`gameobject_template` 2857,
//! `GAMEOBJECT_TYPE_TEXT`), whose `data[0]` is `page_text` 2676, a 647-byte HTML body. The
//! switches are `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::prelude::*;
@@ -35,16 +19,14 @@ use crate::player::Player;
use crate::ui_item_text::ItemTextOpen;
use crate::ui_script::{UiCostWanted, UiFrameCost};
/// The reporter's own standing position beside the plaque (their debug panel, in the report's
/// screenshot).
/// A standing position beside the plaque.
const PLAQUE_AT: [f32; 3] = [-8760.2, 402.3, 103.9];
/// `GAMEOBJECT_TYPE_TEXT` — the strategy type a book/plaque carries (decision 1105).
/// `GAMEOBJECT_TYPE_TEXT`, a book or plaque.
const GO_TYPE_TEXT: i32 = 9;
/// Scan radius around the landing spot, in yards — generous, so a slightly-off `.go` still finds it.
/// Scan radius around the landing spot, in yards.
const SCAN_RANGE: f32 = 20.0;
/// Frames sampled per window. Long enough that a one-off hitch cannot carry the mean, short enough
/// that the whole run is a few seconds.
/// Frames sampled per window, enough that one hitch cannot carry the mean.
const SAMPLE_FRAMES: usize = 240;
const SETTLE_SECS: f64 = 4.0;
const SCAN_TIMEOUT_SECS: f64 = 20.0;
@@ -62,24 +44,21 @@ impl Plugin for ProbeBookPlugin {
/// One frame's reading off the UI meter.
#[derive(Clone, Copy)]
struct Sample {
/// The paint pass — the phase the glyph rasterization and the ellipsis seam live in.
/// The paint pass: glyph rasterization and the ellipsis seam.
convert: u128,
/// Everything the pass costs, whether or not the gate skipped: tick + resolve + measure +
/// extract + convert + diff.
/// The whole UI pass, skipped or not.
total: u128,
/// Did the extract gate skip the conversion this frame (decision 0740)?
/// Whether the extract gate skipped the conversion this frame.
skipped: bool,
}
#[derive(Resource, Default)]
struct BookProbe {
phase: Phase,
/// The plaque's guid, once the scan finds it.
plaque: Option<u64>,
closed: Vec<Sample>,
open: Vec<Sample>,
/// Characters the whole UI draws once the reader is up — the `n` the seam's cost scales in,
/// and the proof the page's blocks were built at all.
/// Characters the whole UI draws once the reader is up.
page_chars: i64,
fails: u32,
exited: bool,
@@ -93,24 +72,19 @@ enum Phase {
Settling {
sent_at: f64,
},
/// Sampling with the reader CLOSED — the control window.
/// Sampling with the reader closed, the control window.
Closed,
/// Reader opened on the real route; waiting for `ITEM_TEXT_READY` to paint a body.
/// Reader opened; waiting for `ITEM_TEXT_READY` to paint a body.
WaitReady {
since: f64,
},
/// Sampling with the reader OPEN — the reported condition.
/// Sampling with the reader open.
Open,
Done,
}
/// The reader's painted state: `(shown, characters the page actually draws)`.
///
/// The body is read off the RENDER LIST, not out of the VM. `ItemTextPageText` is a `SimpleHTML`
/// since decisions 1337/1338 and 5875's SimpleHTML has no `GetText` (its Lua table is 19 entries
/// and none of them is a text getter) — so the honest question is what the reader draws, which is
/// also the stronger one: a parsed page is many blocks, a plain one is a single raw block, and a
/// page that failed to build is zero.
/// The reader's painted state: `(shown, characters drawn)`, read off the render list because the
/// 1.12 `SimpleHTML` has no `GetText`.
fn reader_state(script: &UiScript) -> (bool, i64) {
use benilla_ui::script::QuadContent;
let shown = script
@@ -128,9 +102,8 @@ fn reader_state(script: &UiScript) -> (bool, i64) {
(shown, chars)
}
/// 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` in its method table `0x87ba80`).
/// Every string the UI draws; the only way to read the page, as `SimpleHTML` has no `GetText`
/// (method table `0x87ba80`).
fn drawn_strings(script: &UiScript) -> Vec<String> {
use benilla_ui::script::QuadContent;
script
@@ -144,9 +117,8 @@ fn drawn_strings(script: &UiScript) -> Vec<String> {
.collect()
}
/// **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 — 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.
/// Checks the page's render: no drawn string holds a tag (a failed parse draws the markup), each
/// of the page's lines is its own block, none is ellipsized, and the page fits its window.
fn report_render(script: &UiScript) -> u32 {
let drawn = drawn_strings(script);
let mut fails = 0;
@@ -163,7 +135,7 @@ fn report_render(script: &UiScript) -> u32 {
markup.iter().take(2).collect::<Vec<_>>()
);
}
// The blocks the plaque's own body must produce, each its own centred line.
// Lines of the plaque's body, each its own centred block.
let want = [
"ALLIANCE MILITARY RANKS",
"OFFICERS",
@@ -181,18 +153,15 @@ fn report_render(script: &UiScript) -> u32 {
fails += 1;
error!("PROBE_BOOK: (render) FAIL — blocks missing from the page: {missing:?}");
}
// The other half of the reported look: a truncated block means the body is height-pinned
// again and decision 1332's ellipsis seam has it back.
// A truncated block means the body is height-pinned and the ellipsis seam cuts it.
if let Some(cut) = drawn.iter().find(|t| t.ends_with("...")) {
fails += 1;
error!("PROBE_BOOK: (render) FAIL — a drawn block is ellipsized: {cut:?}");
} else {
info!("PROBE_BOOK: (render) PASS — no block is cut off with \"...\"");
}
// **Does the page FIT?** The director's own comparison against 1.12.1 (08-15) was that ours
// ran off the bottom where the reference's ends inside the window. The scroll range answers it
// exactly and without an eye: it is the content's overhang past the viewport, so this page —
// which the reference shows whole — must come out at zero.
// The reference shows this page whole, so its scroll range (the overhang past the viewport)
// must be zero.
let range = script
.eval::<f64>("return ItemTextScrollFrame:GetVerticalScrollRange()")
.unwrap_or(-1.0);
@@ -250,7 +219,7 @@ fn book_probe(
return; // not in-world yet
}
let Some(script) = script else {
return; // no UI VM this build — nothing this probe can drive
return; // no UI VM in this build
};
let now = time.elapsed_secs_f64();
let sample = Sample {
@@ -262,8 +231,7 @@ fn book_probe(
match phase {
Phase::Wait => {
// Arm the pass's phase split for this run (decision 1174's direction: the consumer
// asks, the pass never learns the instrument's name).
// Arm the pass's per-phase split for this run.
wanted.0 = true;
let [x, y, z] = PLAQUE_AT;
info!("PROBE_BOOK: heading to the Old Town plaque ({x} {y} {z}) — GameObject type 9, page_text 2676");
@@ -301,7 +269,7 @@ fn book_probe(
probe.closed.push(sample);
if probe.closed.len() >= SAMPLE_FRAMES {
let guid = probe.plaque.unwrap_or_default();
info!("PROBE_BOOK: opening the reader on {guid:#x} (the right-click's own route — `ItemTextOpen::open_pages`, decision 1105)");
info!("PROBE_BOOK: opening the reader on {guid:#x} (the right-click's own route — `ItemTextOpen::open_pages`)");
reader.open_pages(guid);
probe.phase = Phase::WaitReady { since: now };
}
@@ -341,9 +309,7 @@ fn book_probe(
open_mean - closed_mean,
probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `AppExit` plus a hard backstop, so a teardown hang cannot keep the account held.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
+20 -49
View File
@@ -1,31 +1,15 @@
//! `WOW_PROBE_CAM` — the scripted **camera park**: hold the third-person rig at an absolute yaw,
//! pitch and zoom so an unattended probe can *look at* a named thing (decision 0653).
//! `WOW_PROBE_CAM`: parks the third-person camera at an absolute yaw, pitch and zoom so an
//! unattended probe can frame a subject; [`super::probe_look`] turns the avatar, never the pitch.
//!
//! [`super::probe_look`] turns the avatar's **aim**; that is the right lever for the facing stream,
//! and the wrong one for framing a subject — it never touches `FlyCam::pitch`, which world entry
//! pins at `-0.45` rad (26° **down**). Everything above the horizon was therefore unreachable
//! headlessly: a `.go` to a pinned coordinate lands with the camera staring at the dirt, so the
//! tower / crystal / rock the report is *about* is off the top of the frame. That is why the first
//! C4 flicker burst came back clean — the subject was never in it.
//! Format: `WOW_PROBE_CAM="<yaw_deg>,<pitch_deg>[,<dist_yd>]@<start_s>[:<pan_deg_per_s>][;...]"`,
//! e.g. `"180,12,25@25"`: from 25 s on, yaw 180, 12 degrees up, 25 yd back. Yaw and pitch are
//! absolute (`+pitch` is up); an omitted distance is left alone. The pose is re-applied every
//! frame, so a teleport or reset cannot knock it off, and the latest armed entry wins. A pan turns
//! the yaw at a constant rate from the pose's start, for defects that only show with the camera
//! moving (z-fighting).
//!
//! Format: `WOW_PROBE_CAM="<yaw_deg>,<pitch_deg>[,<dist_yd>]@<start_s>[:<pan_deg_per_s>][;…]"`,
//! e.g. `"180,12,25@25"` — from 25 s on, face south-ish, 12° **up**, 25 yd back. Yaw and pitch are
//! absolute (world yaw as the rig spells it, `+pitch` = up); the distance is optional and left alone
//! when omitted. Entries are held **every frame** once armed, not set once: a teleport, a settle, or
//! a wire-in reset would otherwise knock the pose off between the arm and the shot. The last armed
//! entry wins, so a `;` list is a sequence of poses.
//!
//! The optional **pan** turns the parked yaw at a constant rate from that pose's own start
//! (`"140,16,22@20:8"` — park, then sweep 8°/s). It exists because a parked camera is blind to a
//! whole class of defect: the director's read of the Far Watch Post tower is that it "mostly only
//! flickers while moving the cam, not while still" — z-fighting's signature, and precisely the
//! condition decision 0653's still burst cannot see (decision 0656). Pair it with `benilla-visual
//! flicker`'s **toggle map**, the reading that survives a moving view.
//!
//! It writes the rig fields directly for the same reason `probe_look` writes `face_yaw` directly —
//! faking mouse motion needs the OS cursor inside the viewport, which an unfocused probe window
//! cannot promise, and the camera *plumbing* is not what is under test. From these fields onward
//! this is the identical path a mouse-drag takes.
//! It writes the rig fields directly, as a mouse-drag would, since faking mouse motion needs the
//! OS cursor inside an unfocused window.
use bevy::prelude::*;
@@ -34,8 +18,7 @@ use benilla_world::schedule::WorldStage;
use super::ProbeClock;
/// One parked pose: absolute yaw/pitch (radians), an optional orbit distance (yards), when it takes
/// over, and an optional constant yaw pan (rad/s) applied from that moment.
/// One parked pose: yaw and pitch (radians), orbit distance (yards), start (s), yaw pan (rad/s).
struct Pose {
yaw: f32,
pitch: f32,
@@ -49,8 +32,7 @@ pub(crate) struct ProbeCam {
poses: Vec<Pose>,
}
/// Parse the env script. Absent or unparseable entries yield no poses (with a warning), so the probe
/// is inert unless asked for.
/// Parses `WOW_PROBE_CAM`; unparseable entries are dropped, and no pose leaves the probe inert.
pub(crate) fn from_env() -> Option<ProbeCam> {
let spec = std::env::var("WOW_PROBE_CAM").ok()?;
let poses: Vec<Pose> = spec
@@ -62,13 +44,13 @@ pub(crate) fn from_env() -> Option<ProbeCam> {
let mut parts = pose.split(',').map(str::trim);
let yaw = parts.next()?.parse::<f32>().ok()?;
let pitch = parts.next()?.parse::<f32>().ok()?;
// A third field is the orbit distance; anything past it is a typo worth refusing.
// A third field is the orbit distance; a fourth is refused.
let distance = match (parts.next(), parts.next()) {
(None, _) => Some(None),
(Some(d), None) => d.parse::<f32>().ok().map(Some),
(Some(_), Some(_)) => None,
}?;
// `@<start>` or `@<start>:<pan>` — the same `at:rate` shape `WOW_PROBE_LOOK` uses.
// `@<start>` or `@<start>:<pan>`, the `WOW_PROBE_LOOK` shape.
let (at, pan) = match at.trim().split_once(':') {
Some((at, pan)) => (at.trim(), pan.trim().parse::<f32>().ok()?),
None => (at.trim(), 0.0),
@@ -88,16 +70,8 @@ pub(crate) fn from_env() -> Option<ProbeCam> {
(!poses.is_empty()).then_some(ProbeCam { poses })
}
/// Hold the camera at the latest armed pose. Runs in `WorldStage::Input` **before** `control`, so
/// the frame that sees the pose is the frame that renders it.
///
/// On the **wall clock** ([`ProbeClock`], decision 0789), like every other probe schedule: `@25`
/// means twenty-five real seconds in, and a pan of `8°/s` means eight degrees per real second. It
/// read `Res<Time>` from 0653 until decision 1174 — clamped to `max_delta` (250 ms) and frozen
/// outright by the capture harness, so a hitching or occluded run silently under-ran every knob.
/// The drift was never caught because this file lived in `player/`, outside the reach of the very
/// checker that exists to catch it ([`super::probes`]); moving the instrument into the harness is
/// what surfaced it.
/// Holds the camera at the latest armed pose, before `control` so the same frame renders it.
/// Times and pan rates are wall-clock ([`ProbeClock`]), not `Res<Time>`, which clamps and freezes.
pub(crate) fn drive_probe_cam(
probe: Res<ProbeCam>,
time: ProbeClock,
@@ -116,19 +90,16 @@ pub(crate) fn drive_probe_cam(
let Ok(mut cam) = cam.single_mut() else {
return;
};
// Absolute, from the pose's own start — so the sweep is reproducible frame-for-frame across
// runs rather than accumulating whatever frame times this run happened to get.
// Absolute from the pose's start, so the sweep does not depend on frame times.
cam.park(pose.yaw + pose.pan * (now - pose.at), pose.pitch);
if let Some(d) = pose.distance {
// `park_distance`, not the wheel target alone: the glide would ease `distance` back toward
// the old target every frame and the parked shot would drift through the whole zoom while
// the burst is running.
// `park_distance`, not the wheel target: the glide would ease back toward the old target.
rig.park_distance(d);
}
}
/// `WOW_PROBE_CAM`'s registration — [`super::probe_look::ProbeLookPlugin`]'s twin, in the same slot
/// (`control` reads the rig right after) and equally inert without the variable.
/// Registers `WOW_PROBE_CAM` in the same slot as [`super::probe_look::ProbeLookPlugin`]; inert
/// without the variable.
pub(crate) struct ProbeCamPlugin;
impl Plugin for ProbeCamPlugin {
@@ -1,20 +1,12 @@
//! The cast-cancel live probe (`WOW_PROBE=castcancel`) — the end-to-end instrument for the cast
//! bar's LOCAL self-cancel (decision 0256 open item 2), inert without the env: once in-world, use
//! the Hearthstone (a 10 s cast that never arms the send guard, so it exercises the
//! 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` →
//! `spell::local_self_cancel` → `CMSG_CANCEL_CAST`).
//! The cast-cancel live probe (`WOW_PROBE=castcancel`): once in-world it uses the Hearthstone (a
//! 10 s cast), then 2 s into the bar presses `W` through `ButtonInput<KeyCode>`, the controller
//! path a player's key takes to `spell::local_self_cancel` and `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
//! it — and the server's echo (`RECV CAST_RESULT failure`) must follow without repainting
//! 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
//! (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 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>`.
//! With `WOW_CAST_TRACE=1` the `LOCAL self-cancel` line must land frames after the `SEND move
//! StartForward` line, and the server's `RECV CAST_RESULT failure` must follow without repainting.
//! Every bar phase change is logged ([`bar_timeline`]) so the hold, burst and fade are measured
//! from timestamps. Non-combat: a failed cancel only hearths the character home. The switches are
//! `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::prelude::*;
@@ -30,7 +22,7 @@ impl Plugin for ProbeCastCancelPlugin {
}
}
/// The bar's phase, read out of the live VM (the transcription's own state fields).
/// The bar's phase, read from the stock `CastingBarFrame`'s state fields.
const BAR_PHASE_CHUNK: &str = "return (function()\n\
local f = CastingBarFrame\n\
if not f then return \"noui\" end\n\
@@ -44,10 +36,9 @@ const BAR_PHASE_CHUNK: &str = "return (function()\n\
return \"shown|\" .. txt\n\
end)()";
/// Log every cast-bar phase transition with a timestamp — the measured timeline of the
/// transcription's hold/burst/fade (decision 0454's pin: `holdTime` 1 s wall-clock, then
/// ~5 flash + ~20 fade ticks normalized to `CASTING_BAR_REF_TICK` 30 Hz ≈ 0.83 s). The deltas
/// between the logged `hold` → `burst` → `fade` → `hidden` lines ARE the verdict; no eyeballing.
/// Logs each cast-bar phase change with a timestamp. Stock `CastingBarFrame.lua` holds
/// `CASTING_BAR_HOLD_TIME` (1 s), then steps the flash by 0.2 and the fade by 0.05 once per
/// `OnUpdate` (`CastingBarFrame.lua:132-147`), so burst and fade last 5 and 20 frames.
fn bar_timeline(
time: ProbeClock,
script: Option<NonSend<benilla_ui::script::UiScript>>,
@@ -60,9 +51,7 @@ fn bar_timeline(
let Some(script) = script else {
return;
};
// Session-keyed (1290) even though this is harness code: the phase string is read *out of* the
// VM, so a stale one would swallow the first transition of the next login — the exact event
// this probe exists to timestamp.
// Session-keyed: a phase string left from the last login would swallow the first transition.
let last = last.get(&script);
let Ok(state) = script.eval::<String>(BAR_PHASE_CHUNK) else {
return;
@@ -85,9 +74,8 @@ struct ProbeCastCancel {
used_at: Option<f32>,
}
/// Scan the backpack for the Hearthstone by item link (a restock can't silently move the
/// probe's target) and use it. `false` = not resolved yet — a fresh character's first login
/// still has the item-query round trip in flight, so the caller retries per frame.
/// Finds the Hearthstone in the backpack by item link and uses it; `false` while the item query
/// is still in flight, so the caller retries each frame.
const USE_HEARTH_CHUNK: &str = "return (function()\n\
for s = 1, GetContainerNumSlots(0) do\n\
local link = GetContainerItemLink(0, s)\n\
@@ -99,10 +87,8 @@ const USE_HEARTH_CHUNK: &str = "return (function()\n\
return false\n\
end)()";
/// The timeline: from **3.0 s** after world-enter, scan-until-resolved for the Hearthstone and
/// use it (deadline 10 s — the link needs the server's item-query echo on a cold cache); **2.0 s
/// into the 10 s cast** press `W`; **+0.3 s** release it. Every phase logs, so a stalled run
/// shows where it stopped.
/// From 3 s after world entry, use the Hearthstone (deadline 10 s), press `W` 2 s into the cast
/// and release it 0.3 s later; every phase logs.
fn cast_cancel_probe(
time: ProbeClock,
mut probe: ResMut<ProbeCastCancel>,
@@ -132,7 +118,7 @@ fn cast_cancel_probe(
probe.phase = 99;
error!("castcancel probe: no Hearthstone link resolved by t+10 s");
}
Ok(false) => {} // still resolving — retry next frame
Ok(false) => {} // still resolving, retry next frame
Err(e) => {
probe.phase = 99;
error!("castcancel probe: {e}");
@@ -1,16 +1,9 @@
//! The character-create live probe (`WOW_PROBE_CHARCREATE="<name>[,race,class,gender[,skin,face,\
//! hair,haircolor,facial]]"`) — the agent-side instrument that machine-verifies the char-create/delete
//! wire against the live server (decision 0423, phase 1), inert without the env. It also keeps the
//! `CharRequest::Create`/`Delete` verbs exercised until their UI lands (the create screen is phase 4,
//! delete's UI is deferred).
//!
//! While parked at character select it sends [`CharRequest::Create`] with the parsed request, logs
//! 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 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.
//! The character-create live probe (`WOW_PROBE_CHARCREATE="<name>[,race,class,gender[,skin,\
//! face,hair,haircolor,facial]]"`), inert without the env. At character select it sends
//! [`CharRequest::Create`], logs the `SMSG_CHAR_CREATE` result byte, then deletes the new
//! character and logs that result too, unless `WOW_PROBE_CHARCREATE_KEEP=1`. Run it without
//! `WOW_CHAR`, which would enter the world; the switches are `docs/CONTRIBUTING.md`, "Running it
//! unattended".
use benilla_protocol::{messages, CharAction, CharCreateReq};
use bevy::prelude::*;
@@ -75,18 +68,18 @@ struct CharCreateProbe {
req: CharCreateReq,
keep: bool,
state: ProbeState,
/// The freshest roster seen (tracked so a successful create can find its guid to clean up).
/// The freshest roster, where a successful create finds its guid to clean up.
roster: Vec<benilla_protocol::Character>,
}
/// Drive the probe's create → (delete) → done sequence off the roster + result messages.
/// Drive create, then delete, off the roster and result messages.
fn drive_charcreate_probe(
mut probe: ResMut<CharCreateProbe>,
pick: Res<CharPick>,
mut lists: MessageReader<CharListMessage>,
mut results: MessageReader<CharActionResultMessage>,
) {
// Always track the freshest roster (the fresh list precedes each successful action's result).
// The fresh roster arrives before each successful action's result.
let got_roster = lists.read().fold(false, |_, m| {
probe.roster = m.characters.clone();
true
@@ -167,8 +160,7 @@ fn drive_charcreate_probe(
}
}
/// A minimal name for the result byte (the create screen has the full 1.12 GlueStrings table; the
/// probe only needs the anchors it verifies against).
/// A name for the result bytes the probe checks; the create screen has the full table.
fn result_label(action: CharAction, code: u8) -> &'static str {
match (action, code) {
(CharAction::Create, messages::CHAR_CREATE_SUCCESS) => "CHAR_CREATE_SUCCESS",
+150 -388
View File
@@ -1,109 +1,24 @@
//! The guild-charter live probe (`WOW_PROBE_CHARTER=1`) — decision 1672's end-to-end instrument:
//! log in, GM-hop to the Stormwind guild registrar, open his gossip menu on the real wire, assert
//! the charter row's icon reads **petition**, select it by its wire index, buy a charter through
//! the registrar's own window, right-click the charter in the bags the way a player does, watch the
//! petition window fill in a round trip later, rename it, and destroy it again so the next run can
//! do the same. One `PROBE_CHARTER: <step> PASS/FAIL/SKIP <detail>` line per step, then a final
//! `PROBE_CHARTER: DONE pass=<n> fail=<m>`. Modeled closely on [`super::probe_binder`] — same phase
//! machine, same trace style, same self-terminating exit ([`super::probes::ProbeExitPlugin`]'s
//! pattern), same live-VM observation idiom (`script.eval` against the real UI VM) — with
//! [`super::probe_clam`]'s bag-click leg (`UseContainerItem` through the live VM, so the real
//! dispatcher runs) and its watch-the-cleanup-land exit.
//! The guild-charter live probe (`WOW_PROBE_CHARTER=1`): hop to the Stormwind guild registrar,
//! open his gossip menu, check the charter row's icon reads `petition`, select it by wire index,
//! buy a charter through the registrar's window, right-click it in the bags, watch the petition
//! window fill a round trip later, rename it and destroy it so the next run can repeat. One
//! `PROBE_CHARTER: <step> PASS/FAIL/SKIP <detail>` line per step, then
//! `PROBE_CHARTER: DONE pass=<n> fail=<m>`. Inert without the env.
//!
//! **Unit tests cannot reach any of this.** Every claim the charter slice rests on is about what a
//! real server sends and in what order: a packet that had no const and no parse arm until 1672
//! landed, a window that deliberately opens *empty* and fills a round trip later, and an item-use
//! fork arm whose whole evidence is "no other arm accepts a charter". Those are only visible
//! against real bytes.
//! The registrar is Aldwin Laughlin (entry 4974, spawn guid 79681, map 0), `npc_flags` `0x601`:
//! the buy handler wants the petitioner flag and also refuses anything that is not a tabard
//! designer (`PetitionsHandler.cpp:44-52`). His menu 708 sends two unconditional rows, the charter
//! at wire index 0 with icon 7 (`petition` in the reference's `0x84b7ac` table) and the tabard
//! designer with icon 8. The charter is entry 5863, flags `0x2000` (`ITEM_FLAG_CHARTER`), no use
//! spell, at 1000 copper (`PetitionsHandler.cpp:38`); a charter name is at most 24 characters
//! (`ObjectMgr.h:401`), digits and spaces allowed.
//!
//! ## The registrar (live-DB verified against the local vmangos, `mangos` DB)
//! One client cannot sign, offer or turn in a charter: that half needs other accounts. Closing the
//! window is not asserted: closing another player's charter sends `MSG_PETITION_DECLINE`
//! (`0x4f3f60`).
//!
//! Aldwin Laughlin, the Stormwind guild registrar — `creature_template.entry = 4974`, spawn
//! `creature.guid = 79681`, **map 0**, position `(-8885.25, 614.395, 95.2576)`,
//! `creature_template.npc_flags = 1537` = `0x601` = GOSSIP | **PETITIONER (0x200)** |
//! TABARDDESIGNER (0x400), `gossip_menu_id = 708`. Both of the top two bits matter: vmangos's
//! buy handler fetches the NPC with `GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_PETITIONER)` and
//! then *also* refuses anything that is not `IsTabardDesigner()`
//! (`Handlers/PetitionsHandler.cpp:44-52`), so a petitioner without the tabard bit sells nothing.
//!
//! **`UNIT_NPC_FLAG_PETITIONER` is `0x200`** (vmangos `Objects/UnitDefines.h:666`). The probe
//! reuses [`crate::target::cursor_mode::npc_flags::PETITIONER`] rather than keeping a second copy
//! — a duplicated flag table is exactly how B249's icon map went stale.
//!
//! ## The charter row (live-DB verified this session)
//!
//! `gossip_menu_option` for menu 708 carries **exactly two rows, both unconditional**
//! (`condition_id = 0`), so a GM probe and a player see the same two — unlike the innkeeper menu
//! [`super::probe_binder`] walks, where GM mode adds holiday rows:
//!
//! | wire index | `option_icon` | `option_id` | text |
//! |------------|---------------|--------------------------------|---------------------------------|
//! | 0 | **7** | 10 (`GOSSIP_OPTION_PETITIONER`)| *"How do I form a guild?"* |
//! | 1 | 8 | 11 (tabard designer) | *"I want to create a guild crest."* |
//!
//! Icon byte **7** is `"petition"` in [`crate::ui_gossip`]'s `GOSSIP_ICON_TYPES` — the client's own
//! `0x84b7ac` table, byte-verified in decision 1335. The probe finds the row **by that icon byte
//! and selects by the row's wire index, never by list position**: that is the lesson
//! [`super::probe_binder`]'s header records, and it is why this probe survives a menu that grows a
//! row. The label guard is a lowercase substring for the same file's other lesson — vmangos
//! prefers `option_broadcast_text` (3413) over the `option_text` column, so an equality test would
//! be asserting which of two copies of the same sentence the server happened to pick.
//!
//! ## The charter item and its price (live-DB + vmangos source, verified this session)
//!
//! Entry **5863** "Guild Charter", `display_id = 16161`, `flags = 8192` (`0x2000` =
//! `ITEM_FLAG_CHARTER`), `max_count = 1`, `inventory_type = 0`, `start_quest = 0`, `page_text = 0`,
//! **no ON_USE spell** and not LOOTABLE. That combination is the whole of
//! [`crate::ui_items::ItemUseRoute::ShowPetition`]'s evidence: every other arm of the item-use fork
//! declines a charter, so before 1672 a right-click reached `Nothing` and sent nothing at all.
//!
//! The price is **1000 copper** (10 silver) — vmangos `GUILD_CHARTER_COST`
//! (`Handlers/PetitionsHandler.cpp:39`), which arrives in `SMSG_PETITION_SHOWLIST`'s `charterCost`
//! field and is what `GetGuildCharterCost()` must quote.
//!
//! `MAX_CHARTER_NAME` is **24** UTF-8 characters (`ObjectMgr.h:401`, enforced by
//! `ObjectMgr::IsValidCharterName`, which allows digits and spaces —
//! `isValidString(…, numericOrSpace = true)`). The probe's names are 22 characters.
//!
//! ## What each step can and cannot conclude
//!
//! | step | what a FAIL there means | `ui_petition` claim it bears on |
//! |------|-------------------------|---------------------------------|
//! | 0 | the probe left a guild behind and `GuildLeave`/`GuildDisband` did not take — every later step is meaningless, because a buy is refused **silently** while guilded | — (re-runnability) |
//! | 1 | environmental: the `.go` was refused or Stormwind never streamed — SKIP, not a defect | — |
//! | 2 | the gossip wire or the B292 text-query hold is broken for this NPC | — |
//! | 3 | the icon table regressed one row over from B249: byte 7 must read `"petition"` | — |
//! | 4 | `SMSG_PETITION_SHOWLIST` is not parsed, does not fire `GUILD_REGISTRAR_SHOW`, or its `charterCost` is not what `GetGuildCharterCost()` reads | **INFERRED #1**, and `script::petition`'s "what `GetGuildCharterCost` reads" |
//! | 5 | the buy never reached the server, or was refused — there is **no confirmation packet at all**, so the item arriving *is* the answer | — |
//! | 6 | using a charter does not open the petition window: either the fork arm or `SMSG_PETITION_SHOW_SIGNATURES` → `PETITION_SHOW` | **INFERRED #6**, and **#2** |
//! | 7 | the lazy record fill is broken — the window would sit titleless forever, which is the whole two-caches design | `script::petition`'s "the repaint path" |
//! | 8 | `MSG_PETITION_RENAME`'s echo does not patch the cached record (the echo is sent **only on success**) | — |
//! | 9 | the charter is still in the bags, and the **next** run's buy will be refused silently for "the owner already has one" (`PetitionsHandler.cpp:70`) — a failure that looks nothing like its cause | — (re-runnability) |
//!
//! Three of `ui_petition`'s six INFERRED claims are **not** touched here, and none of the three is
//! an oversight. #4 (re-asking for the signature list on an `OK` sign result) needs a second
//! 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*: 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
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CHARTER=1 cargo run -q -p benilla
//! ```
//! (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.
//!
//! Non-combat: this probe never fights and never targets anything. GM mode is left exactly as
//! found. An outer `timeout` + grep on `PROBE_CHARTER:` is the whole harness; the probe self-exits
//! once DONE, and every wait is bounded — a hung leg FAILs with a legible detail rather than
//! hanging the run.
//! Non-combat; GM mode is left as found, every wait is bounded and the probe exits once done. How
//! to run it is `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::prelude::*;
@@ -125,65 +40,47 @@ use crate::ui_items::{find_item, ItemSearch, PACK_SLOTS};
use crate::ui_petition::GuildRegistrarState;
use crate::ui_session::NpcSession;
/// Aldwin Laughlin's spawn (vmangos `creature` guid 79681, entry 4974) — the `.go xyz` target.
/// Aldwin Laughlin's spawn, the `.go xyz` target.
const REGISTRAR_AT: [f32; 3] = [-8885.25, 614.395, 95.2576];
/// His map — Eastern Kingdoms. `.go xyz` takes the map id as its fourth argument.
/// Eastern Kingdoms, `.go xyz`'s fourth argument.
const REGISTRAR_MAP: u32 = 0;
/// His creature template entry — the streamed-unit identity check (module doc). The `0x200` npc
/// flag is only the fallback, exactly as the binder probe splits it.
/// His creature entry, the streamed-unit check; the petitioner flag is the fallback.
const REGISTRAR_ENTRY: u32 = 4974;
/// The wire `GOSSIP_ICON` byte menu 708's charter row sends (module doc) — decision 1335's table
/// indexes it to [`ICON_TYPE_PETITION`].
/// The wire icon byte of menu 708's charter row.
const ICON_PETITION: u8 = 7;
/// The type string [`crate::ui_gossip`]'s table must produce for [`ICON_PETITION`].
const ICON_TYPE_PETITION: &str = "petition";
/// What a broken icon table produces instead — the chat bubble. Seeing it back here is B249's
/// regression one row over, not a flake.
/// What a wrong icon table produces instead: the chat bubble.
const ICON_TYPE_REGRESSION: &str = "gossip";
/// The substring the charter row's label must carry before the probe is willing to select it. A
/// lowercase *substring*, never an equality: the label on the wire is the row's
/// `option_broadcast_text` (3413) rather than its `option_text` column, and the exact wording is
/// the server's to choose (module doc).
/// A lowercase substring of the charter row's label: the wire label is the row's broadcast text,
/// not its `option_text` column.
const CHARTER_LABEL_HINT: &str = "guild";
/// Scan radius around the `.go` landing, generously wide so a slightly-off hop still finds him
/// (the bank/mail/binder probes' shared idiom).
/// Scan radius around the `.go` landing.
const SCAN_RANGE: f32 = 12.0;
/// The price `GetGuildCharterCost()` must quote, in copper — vmangos `GUILD_CHARTER_COST`
/// (`PetitionsHandler.cpp:39`), carried in `SMSG_PETITION_SHOWLIST`'s `charterCost`.
/// `GUILD_CHARTER_COST` in copper (`PetitionsHandler.cpp:38`), the showlist's `charterCost`.
const CHARTER_COST_COPPER: i64 = 1000;
/// What `GetPetitionInfo()`'s `maxSignatures` must read once the record lands — vmangos answers
/// `SMSG_PETITION_QUERY_RESPONSE` with `minSignatures = maxSignatures = 9`, hardcoded
/// (`PetitionsHandler.cpp:182-183`), whatever `MinPetitionSigns` is configured to.
/// `GetPetitionInfo()`'s `maxSignatures`: vmangos hardcodes min and max to 9
/// (`PetitionsHandler.cpp:182-183`) whatever `MinPetitionSigns` is.
const REQUIRED_SIGNATURES: i64 = 9;
/// A fresh charter has **no** signatures: `GetNumPetitionNames()` counts signers, and the owner is
/// not one of them (the reference paints the owner into its own font string).
/// `GetNumPetitionNames()` counts signers, and the owner is not one.
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** (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.
/// Copper sent up front so the buy never fails for funds. `.modify money` is `SEC_BASIC_ADMIN`
/// (`Chat.cpp:586`) and targets the sender with nothing selected (`Chat.cpp:2601-2612`), so it
/// goes before any NPC is targeted.
const FUND_COPPER: u32 = 100_000;
/// Settle after the `.go` before scanning — the hop's own travel plus a frame for the stream.
/// Settle after the `.go` before scanning.
const SETTLE_SECS: f64 = 3.0;
/// The waits are deliberately generous, for the reason [`super::probe_binder`] measured: a `.go`
/// can land inside a terrain load, and the probe then gets roughly one frame per second to poll
/// in. A timeout tight enough to trip on that would report a FAIL about the wire, which is the one
/// thing an instrument must never do.
/// Generous: a `.go` can land inside a terrain load, where the probe polls about once a second.
const GUILD_CLEAR_TIMEOUT_SECS: f64 = 20.0;
const SCAN_TIMEOUT_SECS: f64 = 25.0;
const MENU_TIMEOUT_SECS: f64 = 20.0;
/// How long step 4 will wait for the portrait booth's `"npc"` token before reporting it empty.
///
/// Not a gate — the step passes either way. It exists so the detail line says something true: the
/// booth is fed by a system that is deliberately unordered against the apply pass, so the token is
/// `None` on the frame the window opens and resolves on the next.
/// How long step 4 waits for the portrait's `"npc"` token, reported, not asserted:
/// `feed_interact_npc` is unordered against the apply pass, so it resolves a frame late.
const REGISTRAR_TOKEN_SETTLE_SECS: f64 = 1.0;
const REGISTRAR_TIMEOUT_SECS: f64 = 15.0;
/// The buy's wait is the longest of the action legs: nothing acknowledges it, so this is the time
/// the *item* gets to arrive and its template with it.
/// The longest wait: nothing acknowledges a buy, so this is for the item and its template.
const BUY_TIMEOUT_SECS: f64 = 25.0;
const PETITION_TIMEOUT_SECS: f64 = 15.0;
const RECORD_TIMEOUT_SECS: f64 = 15.0;
@@ -199,33 +96,28 @@ impl Plugin for ProbeCharterPlugin {
}
}
/// The probe's phase machine plus the identities discovered along the way (the binder/bank probes'
/// shape: a `Copy` phase snapshotted out of the resource each tick, so an arm can mutate `probe`
/// freely).
/// The probe's phase machine and what it found; the `Copy` phase is snapshotted each tick so an
/// arm can mutate `probe` freely.
#[derive(Resource, Default)]
struct CharterProbe {
phase: Phase,
/// The registrar's guid, once streamed in.
registrar: Option<u64>,
/// The charter row's **wire** `index` — the value the packet carried and the value
/// `CMSG_GOSSIP_SELECT_OPTION` must echo back. vmangos numbers them over the rows it actually
/// sends (`GossipDef.cpp:188`), so it is the row's 0-based position in *this* menu — neither
/// the DB's `gossip_menu_option.id` nor the Lua menu's 1-based position, and the probe assumes
/// no relation between them for the same reason the real drain doesn't.
/// The charter row's wire index, which `CMSG_GOSSIP_SELECT_OPTION` echoes: vmangos numbers the
/// rows it sends from 0 (`GossipDef.cpp:188`), neither the DB id nor the Lua position.
charter_row: Option<u32>,
/// Where the bought charter is: the wire `(bag_index, slot)` pair and the instance guid, from
/// [`find_item`]. The Lua click position is derived from it by [`lua_bag_pos`].
/// The bought charter's wire `(bag_index, slot)` and item guid, from [`find_item`].
charter: Option<(u8, u8, u64)>,
/// The guild name bought in step 5 — what step 7 waits for the title to become.
/// The guild name bought in step 5, the title step 7 waits for.
bought: String,
/// The name step 8 renames to — what step 8 waits for the title to become.
/// The name step 8 renames to, the title step 8 waits for.
renamed: String,
/// The title read at the instant the petition window first became visible (step 6). Step 7
/// asserts it was either empty (the packet carries no text) or already filled.
/// The title when the petition window first showed (step 6): empty, since the packet carries
/// no text, or already filled.
title_at_open: String,
passes: u32,
fails: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
/// Latched once [`Phase::Done`] has fired its exit.
exited: bool,
}
@@ -249,45 +141,44 @@ impl CharterProbe {
enum Phase {
#[default]
Wait,
/// Step 0 — the money is sent and any guild an earlier run founded is being left. `sent`
/// distinguishes "haven't asked yet" from "asked, waiting for the descriptor to clear".
/// Step 0: money sent; leaving any guild an earlier run founded (`sent` once the verb ran).
Unguild {
since: f64,
sent: bool,
},
/// Step 1 — `.go` issued; settling before the world streams the registrar in.
/// Step 1: `.go` sent; settling while the registrar streams in.
Settling {
sent_at: f64,
},
/// Step 2 — `GossipHello` sent; waiting for the parsed menu AND its push into the VM.
/// Step 2: `GossipHello` sent; waiting for the parsed menu and its push into the VM.
Menu {
sent_at: f64,
},
/// Step 4 — the charter row selected; waiting for `SMSG_PETITION_SHOWLIST` to reach
/// Step 4: the charter row selected; waiting for `SMSG_PETITION_SHOWLIST` to reach
/// [`GuildRegistrarState`] and `GUILD_REGISTRAR_SHOW` to open the window.
Registrar {
since: f64,
},
/// Step 5 — the registrar's purchase panel driven; waiting for the charter item to arrive.
/// Step 5: the purchase sent; waiting for the charter item.
Buying {
since: f64,
sent: bool,
},
/// Step 6 — the charter right-clicked through the live VM; waiting for the petition window.
/// Step 6: the charter right-clicked through the live VM; waiting for the petition window.
Opening {
since: f64,
sent: bool,
},
/// Step 7 — waiting for `SMSG_PETITION_QUERY_RESPONSE` to fill the title in.
/// Step 7: waiting for `SMSG_PETITION_QUERY_RESPONSE` to fill the title in.
Record {
since: f64,
},
/// Step 8 — `RenamePetition` run in the live VM; waiting for the echo to patch the record.
/// Step 8: `RenamePetition` run; waiting for the echo to patch the record.
Renaming {
since: f64,
sent: bool,
},
/// Step 9 — the charter destroyed; watching it actually leave the bags before exiting.
/// Step 9: the charter destroyed; waiting for it to leave the bags.
Destroying {
since: f64,
sent: bool,
@@ -296,34 +187,26 @@ enum Phase {
}
// ── The live-VM readings ─────────────────────────────────────────────────────────────────────
// Every one of these answers a default on an eval hiccup and is treated as "nothing observed
// yet", never a panic (the bank/binder probes' idiom): the probe's own timeouts are what turn a
// persistently missing reading into a verdict.
// Each answers a default on an eval error, read as "nothing yet"; the timeouts give the verdict.
/// The `CHAT_MSG_SYSTEM` / `UI_ERROR_MESSAGE` lines seen since the hook went in, newest last.
///
/// This is the probe's window into the family's **silent** refusals: a buy or a rename that the
/// server turns down comes back as `SMSG_GUILD_COMMAND_RESULT` on the *guild* family's channel,
/// which [`crate::ui_guild::lines`] prints as a system line and nothing else records. Without it a
/// refused buy and a lost packet read identically.
/// The `CHAT_MSG_SYSTEM` / `UI_ERROR_MESSAGE` lines seen since the hook went in, newest last: a
/// refused buy or rename comes back only as `SMSG_GUILD_COMMAND_RESULT`, printed as a system line.
fn probe_lines(script: &UiScript) -> Vec<String> {
script
.eval::<Vec<String>>("return ProbeCharterLines or {}")
.unwrap_or_default()
}
/// How many values the live `GetGossipOptions()` returns — flat `(label, type)` pairs, so twice the
/// row count. The probe waits on this rather than assuming the feed already ran this frame, and it
/// is also what makes the wait honour [`crate::ui_gossip`]'s B292 hold: on the first visit to a
/// `text_id` the menu deliberately does not reach the VM until `SMSG_NPC_TEXT_UPDATE` lands.
/// How many values the live `GetGossipOptions()` returns, flat `(label, type)` pairs: on a first
/// visit the menu reaches the VM only once `SMSG_NPC_TEXT_UPDATE` lands.
fn vm_gossip_values(script: &UiScript) -> i64 {
script
.eval::<i64>("local t = { GetGossipOptions() } return table.getn(t)")
.unwrap_or(0)
}
/// The icon **type string** the app mapped for the 1-based menu row `pos` — read exactly where the
/// FrameXML reads it, out of the pushed snapshot through the Era `GetGossipOptions()` vararg.
/// The icon type string for the 1-based menu row `pos`, read through `GetGossipOptions()` as the
/// stock frame reads it.
fn vm_icon_type(script: &UiScript, pos: usize) -> String {
script
.eval::<String>(&format!(
@@ -333,15 +216,8 @@ fn vm_icon_type(script: &UiScript, pos: usize) -> String {
.unwrap_or_default()
}
/// The texture path `BENILLA_GOSSIP_ICONS.petition` resolves to in the live VM — `""` if the table
/// or the key is missing, which would mean the row draws the fallback bubble whatever the app
/// mapped ([`super::probe_binder`]'s second half of the same assert).
/// The charter's own bag tooltip, line by line — `SetBagItem` on the slot the buy landed in, read
/// back out of the live VM.
///
/// Reported in step 8's detail rather than asserted, for §7's reason: the wording and the ORDER are
/// a unit test's job, and what a live run adds is only that the petition record travels from the
/// query response into the bag slot's pushed view at all.
/// The charter's bag tooltip, line by line, from `SetBagItem` in the live VM; step 8 checks only
/// that the renamed title reaches it, the wording is a unit test's.
fn charter_tooltip_lines(script: &UiScript, pos: Option<(i64, u32)>) -> Vec<String> {
let Some((bag, slot)) = pos else {
return Vec::new();
@@ -369,15 +245,15 @@ fn charter_tooltip_lines(script: &UiScript, pos: Option<(i64, u32)>) -> Vec<Stri
.unwrap_or_default()
}
/// `BENILLA_GOSSIP_ICONS.petition` in the live VM, `""` when absent. The stock
/// `GossipFrame.lua:123` builds the icon path from the type string and defines no such table.
fn vm_petition_texture(script: &UiScript) -> String {
script
.eval::<String>("return (BENILLA_GOSSIP_ICONS and BENILLA_GOSSIP_ICONS.petition) or \"\"")
.unwrap_or_default()
}
/// Is a named frame up, asked of the live UI rather than of our own state — the window is what a
/// player looks for. Nil-guarded on the global so a build without the file reads `false` instead of
/// raising.
/// Is a named frame visible in the live UI; a missing global reads `false`.
fn vm_visible(script: &UiScript, frame: &str) -> bool {
script
.eval::<bool>(&format!(
@@ -386,15 +262,15 @@ fn vm_visible(script: &UiScript, frame: &str) -> bool {
.unwrap_or(false)
}
/// `GetGuildCharterCost()` — the registrar's price in copper.
/// `GetGuildCharterCost()`, the registrar's price in copper.
fn vm_charter_cost(script: &UiScript) -> i64 {
script
.eval::<i64>("return GetGuildCharterCost()")
.unwrap_or(-1)
}
/// One of `GetPetitionInfo()`'s six returns, by 1-based position, as a string. With nothing open
/// the getter returns **no values at all**, so every slot reads `""`.
/// One of `GetPetitionInfo()`'s six returns, by 1-based position; `""` with nothing open, when it
/// returns no values.
fn vm_petition_str(script: &UiScript, slot: usize) -> String {
let binds = "_,".repeat(slot - 1);
script
@@ -404,33 +280,29 @@ fn vm_petition_str(script: &UiScript, slot: usize) -> String {
.unwrap_or_default()
}
/// `GetPetitionInfo()`'s fourth return — the signature requirement off the wire.
/// `GetPetitionInfo()`'s fourth return, the signature requirement.
fn vm_petition_max(script: &UiScript) -> i64 {
script
.eval::<i64>("local _,_,_,v = GetPetitionInfo() return v or -1")
.unwrap_or(-1)
}
/// `GetPetitionInfo()`'s sixth return — `isOriginator`, an Era `1`/`nil` (never `true`/`false`).
/// `GetPetitionInfo()`'s sixth return, `isOriginator`: `1` or `nil`, never a boolean.
fn vm_is_originator(script: &UiScript) -> i64 {
script
.eval::<i64>("local _,_,_,_,_,v = GetPetitionInfo() return v or 0")
.unwrap_or(0)
}
/// `GetNumPetitionNames()` — the signers, which never include the owner.
/// `GetNumPetitionNames()`, the signers, never the owner.
fn vm_num_names(script: &UiScript) -> i64 {
script
.eval::<i64>("return GetNumPetitionNames()")
.unwrap_or(-1)
}
/// The inverse of [`crate::ui_items::wire_pos`] for the two regions a **bag right-click** can
/// address: the backpack (Lua container `0`) and the four equipped bags (`1..=4`).
///
/// `None` for anywhere else — the bank, the keyring, a doll slot. A bought charter cannot land in
/// any of them, so a `None` here is the probe refusing to click a slot it cannot name rather than
/// guessing one.
/// The inverse of [`crate::ui_items::wire_pos`] for the backpack (Lua container `0`) and the four
/// equipped bags (`1..=4`); `None` anywhere else.
fn lua_bag_pos(bag_index: u8, slot: u8) -> Option<(i64, u32)> {
if bag_index == BAG_PLAYER_INVENTORY {
let inner = slot.checked_sub(SLOT_PACK_FIRST)?;
@@ -441,10 +313,8 @@ fn lua_bag_pos(bag_index: u8, slot: u8) -> Option<(i64, u32)> {
}
}
/// A guild name that cannot collide with a previous run or a real guild, and cannot be refused for
/// length: a 13-character prefix, a space, and the low 8 digits of the wall clock — 22 characters,
/// inside vmangos's `MAX_CHARTER_NAME` of 24. Digits and spaces are both allowed by
/// `IsValidCharterName` (`isValidString(…, numericOrSpace = true)`, verified this session).
/// A fresh guild name: a 13-character prefix, a space and the low 8 digits of the wall clock, 22
/// characters within `MAX_CHARTER_NAME` (24); `IsValidCharterName` allows digits and spaces.
fn probe_guild_name(prefix: &str) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -471,16 +341,14 @@ fn charter_probe(
return; // not in-world yet
};
let Some(script) = script else {
return; // no UI VM this build (headless net-only) — nothing this probe can drive
return; // no UI VM (headless net-only)
};
let now = time.elapsed_secs_f64();
let phase = probe.phase;
match phase {
Phase::Wait => {
// The refusal channel, installed up front so it is live long before the first verb.
// Both events land on the same list because the probe only ever reads it as "what did
// the client say while this leg ran".
// The refusal channel, installed before the first verb.
if let Err(e) = script.run(
r#"
if not ProbeCharterHooked then
@@ -497,8 +365,8 @@ fn charter_probe(
) {
error!("PROBE_CHARTER: installing the refusal hook: {e}");
}
// Money first, before anything is targeted: `.modify money` with no selection targets
// the sender, and with a *creature* selected it answers "no character selected".
// Money first: with a creature selected, `.modify money` answers "no character
// selected".
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
@@ -513,15 +381,9 @@ fn charter_probe(
sent: false,
};
}
// ── Step 0 — the preconditions ──────────────────────────────────────────────────────
// A buy is refused **silently** while the buyer is in a guild (`PetitionsHandler.cpp:66`,
// a bare `return`), so a run that founded one and never left it would make step 5 fail
// for a reason with no trace anywhere. Leaving is therefore a precondition, not tidiness,
// and it is what makes this probe re-runnable rather than a one-shot.
//
// A FAIL here means `GuildLeave()`/`GuildDisband()` did not take: the descriptor's
// `PLAYER_GUILDID` never came back to 0, which is the same field `IsInGuild()` reads and
// the same edge that fires `PLAYER_GUILD_UPDATE`. Every step below it would be meaningless.
// ── Step 0: the preconditions ───────────────────────────────────────────────────────
// A buy is refused silently while the buyer is in a guild (`PetitionsHandler.cpp:66`), so
// any guild an earlier run founded is left first; FAIL if `PLAYER_GUILDID` never clears.
Phase::Unguild { since, sent } => {
let guild_id = store.0.player_guild_id();
if !sent {
@@ -535,13 +397,9 @@ fn charter_probe(
);
return hop(&mut probe, &net, now);
}
// Disband if we are the master (a guild master cannot simply leave — vmangos
// answers that with `ERR_GUILD_LEADER_LEAVE_S`), else leave. The verb runs through
// the live VM's own globals so the whole engine→drain→wire chain is exercised, but
// the *decision* is read straight off the descriptor rather than out of
// `IsGuildLeader()`: rank 0 IS the guild master, `PLAYER_GUILDRANK` is mirrored
// into the model by a feed this probe is not ordered against, and on the first
// frame in-world that one frame of lag would pick the wrong verb.
// A guild master cannot leave (`ERR_GUILD_LEADER_LEAVE`), so rank 0 disbands.
// The rank is read off the descriptor, not `IsGuildLeader()`, whose feed can lag
// a frame behind on the first frame in-world.
let verb = if store.0.player_guild_rank() == 0 {
"GuildDisband()"
} else {
@@ -590,9 +448,8 @@ fn charter_probe(
probe.phase = Phase::Done;
}
}
// ── Step 1 — the hop ────────────────────────────────────────────────────────────────
// A SKIP here is environmental and says so: the `.go` may have been refused, or Stormwind
// may never have streamed. It is not evidence about the charter flow either way.
// ── Step 1: the hop ─────────────────────────────────────────────────────────────────
// A SKIP here is environmental: the `.go` was refused or Stormwind never streamed.
Phase::Settling { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
@@ -630,11 +487,9 @@ fn charter_probe(
probe.phase = Phase::Done;
}
}
// ── Step 2 — the menu ───────────────────────────────────────────────────────────────
// A FAIL means the gossip wire is broken for this NPC. The wait is for the menu **in the
// VM**, not for the packet: `ui_gossip`'s B292 hold deliberately keeps a first visit's
// menu closed until `SMSG_NPC_TEXT_UPDATE` answers the greeting query, so a wait on the
// parse alone would race the hold and read a menu the player cannot see.
// ── Step 2: the menu ────────────────────────────────────────────────────────────────
// Waits for the menu in the VM, not the packet: `ui_gossip` holds a first visit's menu
// until `SMSG_NPC_TEXT_UPDATE` answers the greeting query.
Phase::Menu { sent_at } => {
let Some(npc) = probe.registrar else {
probe.phase = Phase::Done;
@@ -673,14 +528,9 @@ fn charter_probe(
probe.phase = Phase::Done;
}
}
// ── Step 4's second half — the window ───────────────────────────────────────────────
// **The load-bearing step.** Before decision 1672 `SMSG_PETITION_SHOWLIST` had no const
// and no parse arm: it fell through `parse.rs`'s tail into `ServerPacket::Other`, and
// clicking "How do I form a guild?" closed the gossip window and did nothing else. A FAIL
// here is exactly that shape, and it is the evidence for `ui_petition`'s INFERRED #1 —
// that this packet is what fires `GUILD_REGISTRAR_SHOW`. The cost reading is the second
// half: `GetGuildCharterCost` is modelled as the showlist row's `charterCost` in copper,
// and 1000 is the only number vmangos can send.
// ── Step 4's second half: the window ────────────────────────────────────────────────
// `SMSG_PETITION_SHOWLIST` must fire `GUILD_REGISTRAR_SHOW`, and `GetGuildCharterCost()`
// must read the showlist's `charterCost`, which vmangos always sends as 1000.
Phase::Registrar { since } => {
let Some(npc) = probe.registrar else {
probe.phase = Phase::Done;
@@ -690,17 +540,9 @@ fn charter_probe(
let visible = vm_visible(&script, "GuildRegistrarFrame");
let cost = vm_charter_cost(&script);
if parked && visible && cost == CHARTER_COST_COPPER {
// The portrait booth's `"npc"` token is reported, never asserted: it is what
// paints the window's face and name banner, and a blank one is a look question,
// which is the director's to call and not a probe's.
//
// **Sampled one frame late, on purpose.** `feed_interact_npc` runs in
// `WorldStage::Net` deliberately unordered against the apply pass (its own doc says
// so: a session is open for seconds, so one frame either way is invisible), which
// means on the very frame the registrar opens `InteractNpc` is still `None` and
// this token reads `""` every single time. A detail line that is always empty is an
// instrument that lies — it reads exactly like the black-disc bug this arm was
// added to fix.
// The portrait's `"npc"` token is reported, not asserted, and sampled up to
// `REGISTRAR_TOKEN_SETTLE_SECS` late: `feed_interact_npc` is unordered against
// the apply pass, so it is empty on the frame the window opens.
let token = script
.eval::<String>("return UnitName(\"npc\") or \"\"")
.unwrap_or_default();
@@ -723,15 +565,14 @@ fn charter_probe(
sent: false,
};
} else if parked && visible && cost != CHARTER_COST_COPPER {
// The window opened, so the packet parsed — this is a value bug, not a wire one,
// and saying which saves the next reader the bisect.
// The window opened, so the packet parsed: a value bug, not a wire one.
probe.fail(
4,
"registrar",
format!(
"the registrar opened on {npc:#x} but GetGuildCharterCost() reads {cost}, \
not {CHARTER_COST_COPPER} (vmangos GUILD_CHARTER_COST, \
PetitionsHandler.cpp:39) — the showlist row's charterCost is not what \
PetitionsHandler.cpp:38) — the showlist row's charterCost is not what \
the getter reads, or the visible-row `&1` rule picked the wrong row"
),
);
@@ -744,8 +585,8 @@ fn charter_probe(
"no registrar window within {REGISTRAR_TIMEOUT_SECS}s of the select: \
GuildRegistrarState npc={:?} (wanted {npc:#x}), \
GuildRegistrarFrame:IsVisible()={visible}, GetGuildCharterCost()={cost}. \
Before decision 1672 SMSG_PETITION_SHOWLIST had no parse arm at all and \
fell through to ServerPacket::Other — that is what this reading looks \
An SMSG_PETITION_SHOWLIST with no parse arm at all \
falls through to ServerPacket::Other — that is what this reading looks \
like. Lines seen: {:?}",
registrar.npc(),
probe_lines(&script)
@@ -754,21 +595,12 @@ fn charter_probe(
probe.phase = Phase::Done;
}
}
// ── Step 5 — the buy ────────────────────────────────────────────────────────────────
// Driven through the registrar's own window: `GuildRegistrar_ShowPurchaseFrame()` is the
// reference's pure local panel swap (and the only place the price is painted), then
// `BuyGuildCharter(name)` is the Purchase button's own send. The real button also hides
// the window afterwards (`GuildRegistrar_Purchase`); the probe deliberately does not, so
// the registrar session stays observable — and it costs nothing, because step 6's
// `ShowUIPanel(PetitionFrame)` displaces the left-slot incumbent anyway
// (`SeatLeftAreaPanel`'s two-pushable-0 arm).
//
// **There is no confirmation packet at all for a successful buy** — the item arriving IS
// the answer (`PetitionsHandler.cpp:130` pushes it and nothing else) — so this is a poll
// with a timeout, and a FAIL means either the send never happened or the server refused.
// The four silent refusals are: already in a guild, already owns a petition, the name is
// taken, or not enough money; the name-taken one is the only one that says anything, and
// it says it on the guild family's channel, which is why the detail dumps the lines.
// ── Step 5: the buy ─────────────────────────────────────────────────────────────────
// `GuildRegistrar_ShowPurchaseFrame()` then the Purchase button's `BuyGuildCharter(name)`.
// The stock button also hides the window (`GuildRegistrarFrame.xml:269-270`); the probe
// does not, and step 6's `PetitionFrame` displaces it (both left, pushable 0,
// `UIParent.lua:33-34`). A successful buy has no reply, only the item
// (`PetitionsHandler.cpp:130`); of the refusals only a taken name says anything.
Phase::Buying { since, sent } => {
if !sent {
let name = probe_guild_name("Probe Charter");
@@ -804,13 +636,8 @@ fn charter_probe(
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
// arrives falls through to a plain use and proves nothing — but an early `return` on a
// template that never answers would sail straight past the timeout below and hang the
// run, which is the one thing an instrument may never do. Reading the flags here also
// cross-checks the DB fact (`flags = 8192`) against what the live server sends.
// The template must have landed too: the click's charter arm tests its
// `ITEM_FLAG_CHARTER`. Folded into one `Option` so a missing template still times out.
let ready = found.and_then(|(bag_index, slot, guid)| {
items
.template(CHARTER_ITEM_ENTRY, guid, &net)
@@ -870,8 +697,7 @@ fn charter_probe(
probe_lines(&script)
),
);
// Nothing usable arrived, but `find_item` may still have seen a charter — hand it
// to the cleanup rather than to `Done`, or the next run's buy is refused silently.
// A charter seen without a template still goes to the cleanup.
probe.charter = found;
probe.phase = if found.is_some() {
Phase::Destroying {
@@ -883,34 +709,18 @@ fn charter_probe(
};
}
}
// ── Step 6 — the item-use fork ──────────────────────────────────────────────────────
// Driven as a bag right-click through the live VM's own `UseContainerItem`, so
// `ui_items::drain::drain_container_uses` runs its real dispatcher and reaches
// `ItemUseRoute::ShowPetition` — **not** a synthesized `CMSG_PETITION_SHOW_SIGNATURES`.
// That distinction is the whole point of this step: the arm's position in the fork is
// INFERRED (`ui_petition`'s #6), and the only thing that can exercise it is a real click.
//
// A FAIL means one of two things, and the detail says which it cannot separate: either
// the fork never reached the charter arm (the charter fell through to `Nothing`, which is
// exactly the pre-1672 behaviour — right-click a charter, nothing happens at all), or the
// answer never became a window (INFERRED #2, `SMSG_PETITION_SHOW_SIGNATURES` →
// `PETITION_SHOW`).
//
// **What the window's identity rests on is the control, not a field.** Every reading here
// is the live VM's, and none of the charter getters exposes the open charter's ITEM guid,
// so "the window is on the charter we just bought" is established by the BEFORE control
// (no petition window existed before the click) plus the click itself, rather than by
// matching guids. `PetitionState::open_item` would give the direct form in one term; it is
// private to `ui_petition`, and widening it is that module's call to make, not this
// probe's.
// ── Step 6: the item-use fork ───────────────────────────────────────────────────────
// A real bag right-click through `UseContainerItem`, so `drain_container_uses` reaches
// `ItemUseRoute::ShowPetition`. A FAIL is either the fork missing the charter arm or
// `SMSG_PETITION_SHOW_SIGNATURES` never becoming `PETITION_SHOW`. No getter exposes the
// open charter's item guid, so its identity rests on no window being up before the click.
Phase::Opening { since, sent } => {
let Some((bag_index, slot, guid)) = probe.charter else {
probe.phase = Phase::Done;
return;
};
if !sent {
// The control, the clam probe's first reading: a window that was already up would
// make everything below it meaningless.
// The control: a window already up would make the step meaningless.
if vm_visible(&script, "PetitionFrame") {
probe.fail(
6,
@@ -993,8 +803,8 @@ fn charter_probe(
click: PetitionFrame:IsVisible()={visible}, petitionType={kind:?}, \
isOriginator={originator}, GetNumPetitionNames()={names}. Either the \
item-use fork never reached ItemUseRoute::ShowPetition (a charter that \
falls through to `Nothing` sends nothing at all — the pre-1672 \
behaviour) or the answer never became a window. Lines seen: {:?}",
falls through to `Nothing` sends nothing at all — a silent \
failure) or the answer never became a window. Lines seen: {:?}",
probe_lines(&script)
),
);
@@ -1004,24 +814,16 @@ fn charter_probe(
};
}
}
// ── Step 7 — the lazy record fill ───────────────────────────────────────────────────
// The window opens with an EMPTY title: `SMSG_PETITION_SHOW_SIGNATURES` carries an item
// guid, an owner guid, a petition id and signer guids, and no text at all. The name
// arrives a round trip later on `SMSG_PETITION_QUERY_RESPONSE`, keyed by petition id, and
// the feed re-fires `PETITION_SHOW` so the window repaints in place.
//
// A PASS here is the whole two-caches design observed working end to end. A FAIL means the
// ask-once record query never went out, its answer never landed, or the repaint edge never
// fired — in every one of which the window sits titleless forever, which is what a player
// would report as "the charter has no name".
// ── Step 7: the lazy record fill ────────────────────────────────────────────────────
// `SMSG_PETITION_SHOW_SIGNATURES` carries guids and a petition id but no text; the name
// arrives on `SMSG_PETITION_QUERY_RESPONSE`, keyed by petition id, and the feed re-fires
// `PETITION_SHOW`. A FAIL leaves the window titleless.
Phase::Record { since } => {
let title = vm_petition_str(&script, 2);
let max = vm_petition_max(&script);
if title == probe.bought && max == REQUIRED_SIGNATURES {
// The first half of the claim, checked against what step 6 latched: the title at
// open was either empty (the packet carries none) or already the bought name (the
// record beat the paint). Anything else is a title from somewhere it cannot have
// come from.
// At open the title was empty or already the bought name (the record beat the
// paint); anything else came from another charter's record.
let at_open = probe.title_at_open.clone();
if at_open.is_empty() || at_open == probe.bought {
probe.pass(
@@ -1067,14 +869,10 @@ fn charter_probe(
};
}
}
// ── Step 8 — the rename echo ────────────────────────────────────────────────────────
// `RenamePetition(name)` in the live VM — the `RENAME_GUILD` popup's own Accept. vmangos
// sends `MSG_PETITION_RENAME` back **only on success** (`PetitionsHandler.cpp:209-215`,
// inside `if (petition->Rename(...))`), so the title changing IS the echo arriving; a
// refusal is silent apart from a guild-command result on the other channel.
//
// A FAIL means the echo never arrived or never patched the cached record — the window
// would keep showing the old name indefinitely, since nothing re-queries.
// ── Step 8: the rename echo ─────────────────────────────────────────────────────────
// `RenamePetition(name)`, the `RENAME_GUILD` popup's Accept. vmangos echoes
// `MSG_PETITION_RENAME` only on success (`PetitionsHandler.cpp:209-215`), and nothing
// re-queries, so the echo must patch the cached record.
Phase::Renaming { since, sent } => {
if !sent {
let name = probe_guild_name("Probe Renamed");
@@ -1099,12 +897,8 @@ fn charter_probe(
return;
}
let title = vm_petition_str(&script, 2);
// The bag tooltip has to reach the SAME name, not merely be non-empty. It is fed by a
// different path — the container snapshot, whose rebuild is gated (decision 1439) —
// so the two can disagree, and requiring convergence is what proves the rename's
// record patch reaches the gate rather than only the window. Held inside the step's
// own timeout: a tooltip that never catches up FAILs here rather than being reported
// as a curiosity.
// The bag tooltip must reach the same name: it is fed by the container snapshot,
// whose rebuild is gated, so it can lag the window.
let tip = charter_tooltip_lines(
&script,
probe.charter.and_then(|(b, sl, _)| lua_bag_pos(b, sl)),
@@ -1112,12 +906,8 @@ fn charter_probe(
let tip_caught_up = tip.iter().any(|l| l.contains(&probe.renamed));
if title == probe.renamed && tip_caught_up {
let bought = probe.bought.clone();
// The item TOOLTIP's line 3 while we are here — the charter's guild name and
// master, which the director reported missing. Reported, not asserted: the plate's
// wording and placement are pinned by the unit test
// (`charter_lines_sit_between_the_name_and_the_signable_line`); what only a live
// run can show is that the petition record actually reaches the bag slot's view,
// which is a different question from whether the renderer would print it.
// The tooltip lines are reported; their wording is pinned by
// `charter_lines_sit_between_the_name_and_the_signable_line`.
probe.pass(
8,
"rename",
@@ -1150,30 +940,18 @@ fn charter_probe(
};
}
}
// ── Step 9 — the cleanup that makes the probe re-runnable ───────────────────────────
// `CMSG_DESTROYITEM` on the charter's own wire slot. vmangos cascades that into deleting
// the petition (`Player.cpp:10811-10817`: `if (pItem->IsCharter())` → `DeletePetition`),
// which is the only reason destroying the item is enough.
//
// **Without this, the NEXT run's buy is refused silently** — "Cannot buy a petition if the
// owner already has one", a bare `return` at `PetitionsHandler.cpp:70` — and step 5 would
// fail for a reason that looks nothing like its cause: no packet, no error line, just an
// item that never arrives. That is exactly the failure this step exists to prevent, and it
// is why every exit from steps 5-8 routes through here rather than to `Done`.
//
// The send is watched to land, the clam probe's lesson: `AppExit` tears the net thread
// down within a frame or two, so a fire-and-forget destroy written on the way out never
// reaches the wire.
// ── Step 9: the cleanup ─────────────────────────────────────────────────────────────
// `CMSG_DESTROYITEM` deletes the petition with the charter (`Player.cpp:10811-10817`);
// without it the next run's buy is refused silently (`PetitionsHandler.cpp:70`), so every
// exit from steps 5-8 comes here. The destroy is watched to land before `AppExit` tears
// the net thread down.
Phase::Destroying { since, sent } => {
let Some((bag_index, slot, guid)) = probe.charter else {
probe.phase = Phase::Done; // nothing was bought — nothing to clean up
probe.phase = Phase::Done; // nothing was bought
return;
};
if !sent {
// The position is read FRESH rather than reused from step 5's latch. `count: 0`
// 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.
// The position is read fresh: `count: 0` destroys whatever stack sits there.
let Some((bag_index, slot, _)) = find_item(
&store.0,
&objects,
@@ -1246,9 +1024,7 @@ fn charter_probe(
"PROBE_CHARTER: DONE pass={} fail={}",
probe.passes, probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `AppExit` plus a hard-exit thread, so a teardown hang cannot hold the account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
@@ -1259,7 +1035,7 @@ fn charter_probe(
}
}
/// Step 1's send, shared by both of step 0's exits so the hop is spelled once.
/// Step 1's send, from both of step 0's exits.
fn hop(probe: &mut CharterProbe, net: &NetCommands, now: f64) {
let [x, y, z] = REGISTRAR_AT;
info!(
@@ -1274,19 +1050,8 @@ fn hop(probe: &mut CharterProbe, net: &NetCommands, now: f64) {
probe.phase = Phase::Settling { sent_at: now };
}
/// Steps 3 and 4's first half — the icon, then the click.
///
/// Split out of the phase match because it is one straight line of asserts with several exits, and
/// inlining it buried the phase machine's shape ([`super::probe_binder`]'s own split). Sets
/// `probe.phase` on every path.
///
/// **Step 3's FAIL is the icon-table regression, one row over from B249.** Byte 7 is `"petition"`
/// in the client's own `0x84b7ac` table (decision 1335); a `"gossip"` here means the row draws the
/// chat bubble, which is what the pre-1331 hand-written map did to the innkeeper's byte 5.
///
/// **Step 4's send is by WIRE INDEX**, read off the packet, never derived from where the row sits
/// in the list — the drain's own rule, and the reason a menu that grows a row cannot silently make
/// this probe click the tabard designer.
/// Step 3, the icon (byte 7 is `"petition"` in the reference's `0x84b7ac` table), then step 4's
/// select by the row's wire index, never its list position. Sets `probe.phase` on every path.
fn assert_icon_and_select(
probe: &mut CharterProbe,
gossip: &GossipState,
@@ -1321,8 +1086,8 @@ fn assert_icon_and_select(
"icon",
format!(
"the charter row {:?} (wire icon={ICON_PETITION}) maps to \
{ICON_TYPE_REGRESSION:?}, the chat bubble. That is B249's regression one row \
over: decision 1335's table must index byte 7 to {ICON_TYPE_PETITION:?}",
{ICON_TYPE_REGRESSION:?}, the chat bubble: the binder's icon regression \
one row over: `ui_gossip`'s table must index byte 7 to {ICON_TYPE_PETITION:?}",
opt.message
),
);
@@ -1342,9 +1107,7 @@ fn assert_icon_and_select(
probe.phase = Phase::Done;
return;
}
// The type string being right is not the row drawing right: the XML looks the type up in
// `BENILLA_GOSSIP_ICONS`, and a missing key falls back to the chat bubble with no error
// anywhere. Both halves, exactly as the binder probe asserts them.
// The stock interface defines no `BENILLA_GOSSIP_ICONS` (see `vm_petition_texture`).
let texture = vm_petition_texture(script);
if texture.is_empty() {
probe.fail(
@@ -1371,8 +1134,7 @@ fn assert_icon_and_select(
),
);
// Step 4's click. Guarded on the label — a lowercase substring, never an equality, because the
// wire's label is the row's broadcast text rather than its `option_text` column.
// Step 4's click, guarded on the label substring.
if !opt.message.to_lowercase().contains(CHARTER_LABEL_HINT) {
probe.skip(
4,
+35 -85
View File
@@ -1,51 +1,21 @@
//! The chest live probe (`WOW_PROBE_CHEST=1`) — **B84's instrument**: does the player kneel at an
//! open chest?
//! The chest live probe (`WOW_PROBE_CHEST=1`): does the player kneel at an open chest? The
//! reference arms the loot latch in `OnLootResponse` (`0x5eb900`) as well as on the `CMSG_LOOT`
//! send, and a chest never sends `CMSG_LOOT`.
//!
//! 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).
//! It reads the self unit's
//! [`AnimDriver::active_anim`][crate::creature_anim::AnimDriver::active_anim] in three windows:
//!
//! **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
//! and no eye in the loop. Three readings, in order, which is what makes it a *regression* test and
//! not a one-sided assertion:
//! 1. before, window shut: `0` (`Stand`), the control;
//! 2. open, the chest used through the click's own route: `50` (`Loot`);
//! 3. after `CloseLoot()`: back to `0`.
//!
//! 1. **BEFORE** — parked at the chest, window shut: the base anim must be `0` (`Stand`). This is
//! the control; a probe that only ever sees `50` cannot tell a fix from a stuck kneel.
//! 2. **OPEN** — the chest used on the click's own route, the loot window up: must be `50`.
//! 3. **AFTER** — `CloseLoot()` through the live VM: back to `0`.
//! The use goes through [`crate::target::click::resolve_go_action`], the mouse's lock chain, and
//! sends whichever arm it names; world chests carry a `Skill 0` lock the "Opening" spell meets, so
//! the live arm is `OpenLock`. The DONE line reports the latch (a loot session is open) and the
//! kneel (this kind of target is knelt at) apart: a fishing bobber latches but must not kneel.
//!
//! Nothing here re-decides what packet a chest takes. The probe resolves the object through
//! [`crate::target::click::resolve_go_action`] — the same lock chain the mouse runs (0239/0752) —
//! and sends whichever arm it names, so a chest whose `Lock.dbc` row changes shape still gets the
//! packet the real click would send. (Every world chest carries a lock: `Lock.dbc` 43 is one
//! `LockType 13 "Open Kneeling"` slot, 57 is `5 "Open"` + `6 "Treasure (DND)"`, all `Skill 0`, so
//! the "Opening" spell every character knows satisfies them and the live arm is `OpenLock`.)
//!
//! The DONE line reports the two mechanism halves separately — `latch-while-open` (predicate A: a
//! loot session is open) and `kneel-while-open` (predicate B: this *kind* of target is knelt at,
//! decision 1477). They are not the same question, and the whole of B84's correction lives in the
//! gap: a fishing bobber arms the latch exactly as a chest does and must still read `kneel=false`.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CHEST=1 cargo run -q -p benilla
//! ```
//! (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,
//! which the probe reports as a scan failure rather than as a passing run.
//!
//! Grep `PROBE_CHEST:` for the verdict; the probe self-exits when it lands.
//! `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]` aims it elsewhere; grep `PROBE_CHEST:` for the verdict.
//! The switches are `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::prelude::*;
@@ -60,36 +30,26 @@ use crate::target::click::{resolve_go_action, GoAction};
use crate::target::lock::GoLockInputs;
use crate::ui_loot::{LootKneel, LootLatch, LootState};
/// The probe's default object: the `Worn Wooden Chest` at `gameobject` 3998644 (template 1765,
/// `Lock.dbc` 43 → `LockType 13 "Open Kneeling"`, loot 1570) below Northshire in Elwynn.
///
/// **Not** the reporter's own Mirror Lake `Battered Chest`, deliberately, and the reason is the
/// trap this probe fell into first: every ordinary world chest — that one included — is a
/// **spawn-pool** member (`pool_gameobject`, one of pool 20004's many points spawned at a time), so
/// a fixed coordinate finds nothing most of the time and the probe reads "no chest" where the real
/// answer is "not this point today". 3998644 is unpooled with a 25 s respawn, so it is there every
/// run and it comes back between runs. It is the same GameObject type, the same lock family and the
/// same `SMSG_LOOT_RESPONSE`-only session as the reported object.
/// The default object: the `Worn Wooden Chest` at `gameobject` 3998644 (template 1765,
/// `Lock.dbc` 43, `LockType 13 "Open Kneeling"`) in Elwynn. Ordinary world chests are spawn-pool
/// members, absent from a fixed point most of the time; this one is unpooled with a 25 s respawn.
const CHEST_AT: [f32; 4] = [-9474.37, 111.25, 57.03, 0.0];
/// `GAMEOBJECT_TYPE_CHEST` — the strategy type a chest (and a herb/mining node) carries.
/// `GAMEOBJECT_TYPE_CHEST`, also a herb or mining node.
const GO_TYPE_CHEST: i32 = 3;
/// `AnimationData.dbc` ids the verdict is written in: the kneel we want, and the Stand we must
/// return to. Named rather than inlined so the report reads as animations, not magic numbers.
/// `AnimationData.dbc` ids: the kneel, and the stand to return to.
const ANIM_LOOT: u16 = 50;
const ANIM_STAND: u16 = 0;
/// Scan radius around the landing spot, in yards.
const SCAN_RANGE: f32 = 25.0;
/// Frames each window samples. The base anim is a settled per-frame state, so this is about
/// outlasting a transient, not about statistics.
/// Frames each window samples, enough to outlast a transient.
const SAMPLE_FRAMES: usize = 60;
const SETTLE_SECS: f64 = 5.0;
const SCAN_TIMEOUT_SECS: f64 = 25.0;
const LOOT_TIMEOUT_SECS: f64 = 15.0;
const CLOSE_TIMEOUT_SECS: f64 = 10.0;
/// The streamed-object columns the scan and the lock resolve both read — a `type` alias so the
/// one `Query` stays inside clippy's complexity bar.
/// The streamed-object columns the scan and the lock resolve read.
type NearbyObject = (
&'static Guid,
&'static NetEntity,
@@ -110,16 +70,13 @@ impl Plugin for ProbeChestPlugin {
#[derive(Resource, Default)]
struct ChestProbe {
phase: Phase,
/// The chest's guid, once the scan finds it.
chest: Option<u64>,
/// The base anim ids seen in each window, in frame order.
before: Vec<Option<u16>>,
open: Vec<Option<u16>>,
after: Vec<Option<u16>>,
/// The two mechanism readings taken when the OPEN window finished — reported beside the pose
/// so a failure says *which* half broke. They are deliberately separate: predicate A (a loot
/// session is open — the latch) and predicate B (this KIND of target is knelt at — decision
/// 1477). A fishing bobber is `latch=Some(...) kneel=false`, and that is correct.
/// The latch (a loot session is open) and the kneel (this kind of target is knelt at), read
/// when the open window ends; a fishing bobber is `latch=Some(..) kneel=false`.
latch_open: Option<u64>,
kneel_open: bool,
fails: u32,
@@ -134,25 +91,24 @@ enum Phase {
Settling {
sent_at: f64,
},
/// Sampling with the chest SHUT — the control window.
/// Sampling with the chest shut, the control window.
Before,
/// The use packet is out; waiting for the server to open a loot window.
WaitLoot {
since: f64,
},
/// Sampling with the loot window OPEN — the reported condition.
/// Sampling with the loot window open.
Open,
/// `CloseLoot()` called; waiting for the window to actually go.
/// `CloseLoot()` called; waiting for the window to go.
WaitClosed {
since: f64,
},
/// Sampling after the close — the "and it stands back up" half.
/// Sampling after the close.
After,
Done,
}
/// Where the probe is aimed: `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]`, else [`CHEST_AT`]. Anything
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
/// `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]`, else [`CHEST_AT`] (the common value is `1`).
fn target() -> [f32; 4] {
let Ok(raw) = std::env::var("WOW_PROBE_CHEST") else {
return CHEST_AT;
@@ -168,20 +124,17 @@ fn target() -> [f32; 4] {
}
}
/// Whether the loot window is up, asked of the **live UI** rather than of our own state: the
/// window is what the reporter saw, and `LootFrame` shows only once the feed has fired
/// `LOOT_OPENED` off the wire. Falls back to the app-side session when the frame can't be read.
/// Whether `LootFrame` is shown (after `LOOT_OPENED`), else the app-side loot session.
fn window_open(script: &UiScript, loot: &LootState) -> bool {
script
.eval::<bool>("return LootFrame:IsShown() and 1 or nil")
.unwrap_or_else(|_| loot.source().is_some())
}
/// One window's verdict line: what the base slot held, how steadily, and whether it is the id the
/// reference shows. `expect` is the id every frame of the window must carry.
/// One window's verdict line; `expect` is the id every frame of the window must carry.
fn report(label: &str, expect: u16, seen: &[Option<u16>]) -> u32 {
let held = seen.iter().filter(|a| **a == Some(expect)).count();
// What it held instead, most-common first — the useful half of a failure.
// What it held instead, in first-seen order.
let mut other: Vec<String> = Vec::new();
for a in seen.iter().filter(|a| **a != Some(expect)) {
let name = a.map_or_else(|| "none".to_string(), |id| id.to_string());
@@ -231,7 +184,7 @@ fn chest_probe(
return; // not in-world yet
};
let Some(script) = script else {
return; // no UI VM this build — nothing this probe can drive
return; // no UI VM in this build
};
let now = time.elapsed_secs_f64();
let anim = driver.active_anim();
@@ -240,7 +193,7 @@ fn chest_probe(
Phase::Wait => {
let [x, y, z, map] = target();
info!(
"PROBE_CHEST: heading to the chest ({x} {y} {z} map {map}) — B84's object, \
"PROBE_CHEST: heading to the chest ({x} {y} {z} map {map}) — the kneel target, \
GameObject type {GO_TYPE_CHEST}"
);
let _ = net.0.send(ClientCommand::Chat {
@@ -286,8 +239,7 @@ fn chest_probe(
probe.phase = Phase::Done;
return;
};
// The click's own route (0239/0752): ask the shared lock chain what the right-click
// would send, then send exactly that.
// Send exactly what the right-click's lock chain would send.
let go = objects
.iter()
.find(|(g, ..)| g.0 == guid)
@@ -376,9 +328,7 @@ fn chest_probe(
probe.latch_open.map(|g| format!("{g:#x}")),
probe.kneel_open,
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `AppExit` plus a hard backstop, so a teardown hang cannot keep the account held.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
+41 -86
View File
@@ -1,50 +1,22 @@
//! The openable-item live probe (`WOW_PROBE_CLAM=1`) — **the clam instrument**: right-click a
//! LOOTABLE item in the bags and does a loot window actually open?
//! The openable-item live probe (`WOW_PROBE_CLAM=1`): right-clicks a lootable item in the bags
//! and checks a loot window opens. The click makes two writes before the send (`0x5edc80`): the
//! grey lock ([`PendingItemOps`], `0x4953e0` at `0x5edcd9`) and the loot latch ([`LootLatch`],
//! `0x5edcc0`). vmangos answers `CMSG_OPEN_ITEM` with `SendLoot(item guid, LOOT_CORPSE)`, so
//! `SMSG_LOOT_RESPONSE` comes back type 1 on the item's own guid, which a cold latch refuses.
//!
//! 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`):
//! The click runs through the live VM's `UseContainerItem(0, slot)`, the bag button's own call;
//! each reading is `LootFrame:IsShown()` plus the session guid:
//!
//! - the **grey lock** ([`PendingItemOps`], `0x4953e0` at `0x5edcd9`) — the half that kept working;
//! - the **loot latch** ([`LootLatch`], `0x5edcc0`) — the half decision 1477 left unmodelled, on
//! the reading that an item arm changes no pose. It changes no pose and it is still load-bearing:
//! vmangos answers `CMSG_OPEN_ITEM` with `SendLoot(item guid, LOOT_CORPSE)`, so
//! `SMSG_LOOT_RESPONSE` comes back **type 1** on the item's own guid, and 1477's admission gate
//! *refuses* a type-1 answer against a cold latch — bounces a `CMSG_LOOT_RELEASE` and opens
//! nothing. Grey clam, no window, forever. Decision 1531.
//! 1. BEFORE: the item unclicked, no window (the control).
//! 2. OPEN: after the click, a window on the item's own guid, its slot grey.
//! 3. CLOSED: `CloseLoot()` takes the window down and clears the latch.
//! 4. REOPEN: the same item clicked again opens a second time.
//!
//! **The window is a number here, not a picture.** The probe drives the click through the live UI
//! VM's own `UseContainerItem(0, slot)` — the same binding the bag button calls, so the whole
//! dispatcher (`ui_items::drain::drain_container_uses`) runs for real — and then reads
//! `LootFrame:IsShown()` plus the app-side session guid. Four readings, in order, which is what
//! makes it a regression test rather than a one-sided assertion:
//!
//! 1. **BEFORE** — the clam sitting in the bag, unclicked: no loot window. The control; a probe
//! that only ever sees a window cannot tell a fix from a window that was already up.
//! 2. **OPEN** — after the click: a window, whose session guid is the **clam's own item guid**
//! (not a corpse's, not zero), with the grey lock on its slot.
//! 3. **CLOSED** — `CloseLoot()` through the live VM: the window goes and the latch clears.
//! 4. **REOPEN** — the same clam clicked again: it opens a second time. The adjacent state that
//! catches a latch which arms once and never re-arms (the classic "works the first time" shape).
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CLAM=1 cargo run -q -p benilla
//! ```
//! (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.
//!
//! Auto-loot is forced **off** for the run: with it on, the clam's single row is taken at the open
//! edge and the last-row auto-release closes the window within a frame or two, which would read as
//! "no window" for the wrong reason.
//!
//! Grep `PROBE_CLAM:` for the verdict; the probe self-exits when it lands.
//! `WOW_PROBE_CLAM=<entry>` aims it at another openable template; the default is a Small
//! Barnacled Clam (7973, lootable with no lock). The probe `.additem`s its own copy and removes it
//! on the way out. Auto-loot is forced off: it would close the window within a frame or two. It
//! logs `PROBE_CLAM:` lines and exits; the switches are `docs/CONTRIBUTING.md`, "Running it
//! unattended".
use bevy::prelude::*;
@@ -56,9 +28,8 @@ use crate::net::{ChatKind, ClientCommand, NetCommands, ObjectStore, Objects, Sel
use crate::pending_item_ops::PendingItemOps;
use crate::ui_loot::{LootConfig, LootLatch, LootState};
/// The probe's default template: "Small Barnacled Clam" (entry 7973) — the director's own case, and
/// the `benilla-world --open-item` wire probe's. Its `item_loot_template` row (a Zesty Clam Meat)
/// is what the window must show.
/// The default template, Small Barnacled Clam (entry 7973); its `item_loot_template` row (a Zesty
/// Clam Meat) is what the window must show.
const CLAM_ENTRY: u32 = 7973;
/// How long to wait for `.additem` to land the item and its template in the bags.
@@ -69,18 +40,12 @@ const OPEN_TIMEOUT_SECS: f64 = 10.0;
const CLOSE_TIMEOUT_SECS: f64 = 10.0;
/// How long the `.additem -1` gets to take the probe's own copy back out of the bags.
const CLEANUP_TIMEOUT_SECS: f64 = 8.0;
/// How long the REOPEN round waits after the window goes down before clicking again.
///
/// **Not padding — a real race the reference shares.** `CloseLoot()` sends `CMSG_LOOT_RELEASE` and
/// clears the latch locally at the *send* (`0x48f2c9` in `CloseInteraction 0x48f200`); the server's
/// `SMSG_LOOT_RELEASE_RESPONSE` clears it again, **guid-matched** (`0x5ec0d4`). Re-open the *same*
/// object inside that round trip and the response's clear lands on the latch the re-open just
/// armed, so its type-1 answer is refused — guid-matching cannot separate them when both guids are
/// the same object. Both clear sites are the reference's own, so this is faithful rather than ours
/// to fix; a hand re-click never gets near the ~40 ms window, and a probe clicking on the next
/// frame does, every few runs. One second is a human's reaction time, generously.
/// How long the REOPEN round waits after the window goes down before clicking again. A race the
/// reference shares: `CloseLoot()` clears the latch at the send (`0x48f2c9` in `CloseInteraction`,
/// `0x48f200`) and `SMSG_LOOT_RELEASE_RESPONSE` clears it again, guid-matched (`0x5ec0d4`), so a
/// re-open of the same object inside that round trip is refused.
const REOPEN_SETTLE_SECS: f64 = 1.0;
/// Frames the BEFORE control samples — long enough to outlast a transient, short enough to be free.
/// Frames the BEFORE control samples.
const CONTROL_FRAMES: usize = 60;
pub(crate) struct ProbeClamPlugin;
@@ -92,8 +57,7 @@ impl Plugin for ProbeClamPlugin {
}
}
/// Which template the probe opens: `WOW_PROBE_CLAM=<entry>`, else [`CLAM_ENTRY`]. Anything
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
/// Which template the probe opens: `WOW_PROBE_CLAM=<entry>`, else [`CLAM_ENTRY`] (also for `1`).
fn target_entry() -> u32 {
std::env::var("WOW_PROBE_CLAM")
.ok()
@@ -108,11 +72,9 @@ struct ClamProbe {
/// The clam's item guid and its 1-based Lua backpack slot, once the stock scan finds it.
clam: Option<u64>,
slot: u32,
/// Frames sampled with the clam untouched — every one must show no window.
/// Frames sampled with the clam untouched; every one must show no window.
control: Vec<bool>,
/// What each open round read: the session guid the window came up on, and whether the clicked
/// slot was grey at the same moment. Reported separately because the director's report split
/// exactly there — grey yes, window no.
/// What each open round read: the window's session guid, and whether the clicked slot was grey.
opened: Vec<Option<u64>>,
greyed: Vec<bool>,
/// Whether the latch was clear after each `CloseLoot()`.
@@ -129,11 +91,11 @@ const ROUNDS: u8 = 2;
enum Phase {
#[default]
Wait,
/// `.additem` issued; waiting for the item AND its template to reach the bags.
/// `.additem` issued; waiting for the item and its template to reach the bags.
Stocking {
sent_at: f64,
},
/// Sampling with the clam untouched — the control window.
/// Sampling with the clam untouched, the control.
Before,
/// `UseContainerItem` queued; waiting for a loot window. `round` counts from 0.
WaitOpen {
@@ -158,8 +120,7 @@ enum Phase {
Done,
}
/// Send the probe's own copy back and enter [`Phase::Cleanup`] — the one way out of the round loop,
/// so no exit path can skip the cleanup.
/// Send the probe's own copy back and enter [`Phase::Cleanup`], the one way out of the round loop.
fn finish(net: &NetCommands, entry: u32, now: f64) -> Phase {
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
@@ -169,18 +130,16 @@ fn finish(net: &NetCommands, entry: u32, now: f64) -> Phase {
Phase::Cleanup { since: now }
}
/// Whether the loot window is up, asked of the **live UI** rather than of our own state: the window
/// is what the director looked for, and `LootFrame` shows only once the feed has fired
/// `LOOT_OPENED` off the wire. Falls back to the app-side session when the frame can't be read.
/// Whether the live `LootFrame` is shown, which needs `LOOT_OPENED` to have fired; falls back to
/// the app-side session when the frame cannot be read.
fn window_open(script: &UiScript, loot: &LootState) -> bool {
script
.eval::<bool>("return LootFrame:IsShown() and 1 or nil")
.unwrap_or_else(|_| loot.source().is_some())
}
/// Find the probe's own copy of `entry` in the backpack: its item guid and 1-based Lua slot. The
/// template has to have landed too — the click dispatcher's open arm is a **template** LOOTABLE
/// test, so a click made before the answer arrives falls through to a plain use and proves nothing.
/// The probe's copy of `entry` in the backpack: its item guid and 1-based Lua slot, once its
/// template has landed too, since the dispatcher's open arm tests the template's lootable flag.
fn find_in_backpack(
store: &ObjectStore,
entry: u32,
@@ -223,7 +182,7 @@ fn clam_probe(
return; // not in-world yet
};
let Some(mut script) = script else {
return; // no UI VM this build — nothing this probe can drive
return; // no UI VM in this build, so nothing to drive
};
let now = time.elapsed_secs_f64();
let entry = target_entry();
@@ -257,7 +216,7 @@ fn clam_probe(
refuses silently when the backpack is full. This is NOT a passing run"
);
probe.fails += 1;
probe.phase = Phase::Done; // nothing was stocked — nothing to clean up
probe.phase = Phase::Done; // nothing was stocked, so nothing to clean up
}
}
Phase::Before => {
@@ -328,10 +287,8 @@ fn clam_probe(
}
}
Phase::Cleanup { since } => {
// Put the character back as we found it, and **watch it land**: `AppExit` tears the net
// thread down within a frame or two, so a fire-and-forget `.additem -1` written on the
// way out never reaches the wire (the first run of this probe left its clam behind
// exactly that way). Leave when the copy is gone, or say plainly that it isn't.
// Wait for the removal to land: `AppExit` tears the net thread down within a frame or
// two, so a `.additem -1` sent on the way out never reaches the wire.
let gone = probe
.clam
.is_some_and(|guid| (0..16u8).all(|i| store.0.player_pack_slot(i) != Some(guid)));
@@ -351,9 +308,8 @@ fn clam_probe(
}
probe.exited = true;
report(&probe, entry);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
// teardown hang cannot leave a client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
@@ -364,9 +320,8 @@ fn clam_probe(
}
}
/// Right-click the clam the way the bag button does — through the live VM's own
/// `UseContainerItem`, so `ui_items::drain::drain_container_uses` runs its real dispatcher (and its
/// real open arm) rather than the probe re-deciding which packet an openable item takes.
/// Right-click the clam through the live VM's `UseContainerItem`, so the real dispatcher
/// (`ui_items::drain::drain_container_uses`) picks the packet.
fn click(script: &mut UiScript, probe: &mut ClamProbe, round: u8) {
let slot = probe.slot;
info!("PROBE_CLAM: round {round} — UseContainerItem(0, {slot}) through the live VM");
@@ -1,11 +1,8 @@
//! The sea-crossing live probe (`WOW_PROBE=crossing`) — decision 0455's instrument, inert
//! without the env: once in-world, wait for a cross-continent transport docked on our map,
//! GM-drop onto its deck (`.go xyz`; probe accounts are gmlevel 6), then just stand there and
//! 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 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).
//! The sea-crossing live probe (`WOW_PROBE=crossing`): once in-world, waits for a cross-continent
//! transport docked on our map, drops onto its deck with `.go xyz`, and logs a `PROBE crossing:`
//! line at each edge: aboard, map flip (the riding `NEW_WORLD` branch), still riding, docked on the
//! far continent. Non-combat. It needs the checkout's own probe account, since a shared one is
//! kicked mid-ride; the switches are `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::prelude::*;
@@ -24,15 +21,12 @@ impl Plugin for ProbeCrossingPlugin {
}
}
/// The probe's phase machine. `Wait` → (deck drop sent) `Boarding` → (ride attached) `Aboard` →
/// (CurrentMap flipped, still riding) `Crossed` → (docked on the new map, still riding) done.
/// A failed boarding (window closed under the settle, landed in the drink) retries the next
/// docked window; a lost ride after the flip is a loud FAILURE line.
/// `Wait`, `Boarding` (drop sent), `Aboard` (ride attached), `Crossed` (map flipped, still
/// riding), `Done`. A missed boarding retries at the next dock; a ride lost after the flip FAILs.
#[derive(Resource, Default)]
struct CrossingProbe {
phase: Phase,
/// The one-shot [`BOOTSTRAP_DOCK`] send — so a body that still cannot see a ferry after the
/// hop (wrong continent, a `.go` the server refused) doesn't re-teleport every frame.
/// Latches the one dock hop, so a body that still sees no ferry does not hop every frame.
bootstrapped: bool,
}
@@ -55,41 +49,18 @@ enum Phase {
Done,
}
/// Yards above the boat's sampled origin the GM drop aims: high enough to clear the deck
/// wherever the model origin sits, low enough that the post-teleport settle hold (6 s) + the free
/// fall land well inside the dock window.
///
/// **10 was not high enough, and the failure was silent.** A taxi path's node `z` is the
/// *waterline* for the sea ferries — every node of path 241 carries `z = 0` — while their decks
/// stand ~16 yd above it, so the drop materialised inside the hull and the probe reported
/// "boarding missed the window" forever without ever printing a `transport: board`. The zeppelins
/// hid it: their nodes sit at deck height (71.08), where 10 yd clears fine. Anything on the boat
/// counts as boarding — `owning_transport` walks up to the `Transport` root — so overshooting a
/// cabin roof is harmless and undershooting is not.
/// Yards above the boat's sampled origin the drop aims. A sea ferry's path nodes sit at the
/// waterline (path 241 is all `z = 0`) with the deck ~16 yd above, while zeppelin nodes sit at deck
/// height; landing anywhere on the boat counts, so overshooting is harmless.
const DROP_HEIGHT: f32 = 25.0;
/// Seconds after the deck drop before conceding the boarding failed (settle 6 s + fall + attach,
/// with slack) and re-arming for the next docked window.
/// Seconds after the drop before the boarding counts as missed (6 s settle, fall, attach, slack).
const BOARD_DEADLINE: f64 = 15.0;
/// Solid ground on the Booty Bay pier, beside the Ratchet ferry's berth (WoW coords) — where the
/// probe sends itself when no cross-continent transport is in range at all.
///
/// The probe used to *assume* it was standing at a dock: its `Wait` arm only reacts to a transport
/// the server has already put in visibility range, so a probe body parked anywhere else waited
/// forever, printing nothing. That made the instrument unrunnable from a cold login, which is the
/// only way an unattended session ever starts it.
/// The Booty Bay pier beside the Ratchet ferry's berth (WoW coords), where the probe goes when no
/// cross-continent transport is in range.
const BOOTSTRAP_DOCK: [f32; 4] = [-14297.2, 531.0, 8.8, 0.0];
/// `WOW_PROBE_DOCK=x,y,z[,map]` — go to *this* dock, before waiting for anything. `map` defaults
/// to 0 and **must be sent**: `.go xyz` without one teleports within the map you are *currently*
/// on, so a probe that has already crossed to Kalimdor and is then aimed at a Booty Bay dock lands
/// at Azeroth's coordinates on Kalimdor — off that map's tile grid, where nothing streams and the
/// loading cover can never clear. This probe did exactly that and hung, which is the bug it was
/// written to chase, arrived at from the other end.
///
/// Without it the probe rides whichever cross-continent ferry the login happens to be standing
/// next to, which is not a choice at all: the 1.12 fleet's seams are not interchangeable (one
/// path crosses mid-cycle, another crosses at the cycle wrap), so "it worked" on the ferry that
/// answered says nothing about the one a report names. Overriding the destination is how a
/// specific seam gets measured.
/// `WOW_PROBE_DOCK=x,y,z[,map]`: go to this dock first, to measure a named ferry's seam (one
/// path crosses mid-cycle, another at the cycle wrap). `map` defaults to 0 and is always sent:
/// `.go xyz` without one stays on the current map (vmangos `TeleportCommands.cpp:871`).
fn dock_override() -> Option<[f32; 4]> {
let raw = std::env::var("WOW_PROBE_DOCK").ok()?;
let mut it = raw.split(',').map(|p| p.trim().parse::<f32>());
@@ -102,16 +73,12 @@ fn dock_override() -> Option<[f32; 4]> {
}
}
}
/// How near [`dock_override`]'s named dock the body must stand before the probe will board
/// anything — wide enough to cover a pier and the ferry moored along it, tight enough that the
/// login spot on another continent can never satisfy it.
/// How near [`dock_override`]'s dock the body must stand before boarding anything.
const DOCK_ARRIVED_YD: f32 = 200.0;
/// How near the body a docked ferry must be before the probe will drop onto it — see the scan.
/// Generous enough for a long pier, far short of the next dock on the same continent.
/// How near a docked ferry must be to drop onto it: a long pier, short of the next dock.
const BOARDABLE_YD: f32 = 400.0;
/// Seconds the probe watches for a cross-continent transport before sending itself to
/// [`BOOTSTRAP_DOCK`] — long enough for a login's object stream to deliver one if we are already
/// somewhere it sails from.
/// Seconds to wait for the login's object stream to show a ferry before going to
/// [`BOOTSTRAP_DOCK`].
const BOOTSTRAP_AFTER: f64 = 20.0;
fn crossing_probe(
@@ -132,24 +99,13 @@ fn crossing_probe(
let now = time.elapsed_secs_f64();
match probe.phase {
Phase::Wait => {
// Nothing that crosses the sea is in range at all — we are not at a ferry dock. Send
// the body to one (once; `bootstrapped` latches) rather than waiting out the run.
// With no sea-crossing transport in range, go to a dock once.
let forced = dock_override();
let adrift = !transports
.iter()
.any(|(_, t, _)| t.touches_map(0) && t.touches_map(1));
// An explicit `WOW_PROBE_DOCK` goes FIRST and waits for nothing: the whole point of
// naming a dock is to ride *that* ferry, and the login spot very often has a different
// one already in range — which the scan below would board within the grace period,
// silently measuring the wrong seam. (It did exactly that: a run aimed at Booty Bay
// rode the Undercity zeppelin instead.) Only the "no ferry anywhere" fallback waits,
// because that one is guessing and should let the object stream finish first.
// **Having SENT the hop is not having ARRIVED.** `.go` is a server round trip; the
// scan below runs again on the very next frame, when the body is still standing where
// it logged in and the old surroundings' ferry is still in range — so it boards that
// one and measures the wrong seam, which is precisely the failure the override exists
// to prevent. (Aimed at Booty Bay, it rode the Menethil–Theramore ferry.) So while a
// dock is named, nothing is boarded until the body is actually standing at it.
// A named dock hops at once, and nothing is boarded until the body stands there: the
// `.go` is a round trip, and the login spot's own ferry is still in range meanwhile.
if let Some(dock) = forced {
let here = benilla_assets::coords::bevy_to_wow(player.pos);
let (dx, dy) = (here[0] - dock[0], here[1] - dock[1]);
@@ -173,7 +129,7 @@ fn crossing_probe(
"no cross-continent transport in range"
}
);
// The map id is not optional — see [`dock_override`].
// Always send the map id: `.go xyz` without one stays on the current map.
let _ = net.0.send(ClientCommand::Chat {
kind: crate::net::ChatKind::Say,
target: None,
@@ -184,9 +140,8 @@ fn crossing_probe(
if adrift {
return;
}
// A cross-continent transport (the 1.12 fleet crosses EK↔Kalimdor, maps 0↔1),
// currently docked on OUR map: drop onto its deck. `sample.pos` is WoW coords —
// exactly what `.go xyz` takes.
// A transport touching maps 0 and 1, docked on our map: drop onto its deck.
// `sample.pos` is in WoW coords, as `.go xyz` takes.
for (guid, transport, anchor) in &transports {
if !(transport.touches_map(0) && transport.touches_map(1)) {
continue;
@@ -195,12 +150,8 @@ fn crossing_probe(
if sample.map != map || sample.moving {
continue;
}
// **And it has to be a ferry we are actually standing next to.** A cross-continent
// `.go` leaves the departed surroundings' objects in the entity list until the
// server gets round to removing them, so the scan can otherwise pick a boat
// thousands of yards away and `.go` onto its deck — undoing the hop that just
// placed us, and measuring whichever seam that boat happens to cross. Aimed at
// Booty Bay, this probe boarded the Tirisfal zeppelin twice that way.
// Only a nearby ferry: after a cross-continent `.go` the departed surroundings'
// objects stay in the entity list until the server removes them.
let here = benilla_assets::coords::bevy_to_wow(player.pos);
let (dx, dy) = (here[0] - sample.pos[0], here[1] - sample.pos[1]);
if (dx * dx + dy * dy).sqrt() > BOARDABLE_YD {
+39 -88
View File
@@ -1,73 +1,40 @@
//! The probe fleet's **environment registry** — every `WOW_PROBE*` variable the app reads, in one
//! table the code is checked against in both directions (decision 2265 §A5).
//!
//! The set used to live in three places that could not see each other: the read sites (one per
//! variable, spread over the fleet), a hand-kept arming list in `dev.rs` for the un-occludable
//! window ([`super::ProbeFocusPlugin`], decision 0906), and nothing at all that said what the
//! fleet accepts. The arming list is the one that bit: 0906's rule is that *every* scripted
//! probe defends itself against the macOS occlusion throttle, and the list had drifted to ten of
//! the twenty-five variables that schedule on the wall clock — a mail or auction probe launched
//! exactly as the method prescribes ran covered, at ~1 fps, measuring garbage.
//!
//! So the list is now a **column** here ([`ProbeVar::wall_clock`]), the arming reads the column,
//! and two structural tests below keep the table honest: every `"WOW_PROBE…"` string literal in
//! the crate is a row, every row is a literal somewhere other than this file, and every value
//! `WOW_PROBE` itself dispatches on is in [`PROBE_NAMES`]. `WOW_PROBE=list` prints it.
//!
//! What this is **not**: a dispatcher. The variables keep their own read sites and their own
//! shapes — several carry free-form values (Lua chunks, chat lines with spaces and `=`, `A>B`
//! drag specs) and several combine in one run — so folding them into one variable would need an
//! escaping mini-language and would change the launch line every session uses. The defect was
//! the drift, and a registry is the whole cure for drift.
//! The probe environment registry: every `WOW_PROBE*` variable the app reads, with whether it
//! schedules on the wall clock ([`ProbeVar::wall_clock`], which arms [`super::ProbeFocusPlugin`]).
//! The tests keep it in step with the code both ways; `WOW_PROBE=list` prints it. The variables
//! keep their own read sites: this is a registry, not a dispatcher.
/// One probe-fleet environment variable.
pub(crate) struct ProbeVar {
/// The variable, exactly as the read site spells it.
pub name: &'static str,
/// One line: the value shape and what setting it does, from the comment at the read site.
/// One line: the value shape and what setting it does.
pub purpose: &'static str,
/// Whether the variable **schedules on elapsed real time** — its script fires at `<secs>`
/// marks, integrates a rate per frame, or steps a phase machine on `Time<Real>` — so the run
/// is only right while frames keep arriving at full rate.
///
/// Why it matters (decision 0906): macOS drops a fully covered window to ~1 fps drawables,
/// and on such a window a wall-clock probe does not measure slowly, it **runs the wrong
/// script** — one leg fired `W@16` and `Space@19` in the same frame and jumped from a
/// standstill; another integrated its camera at 125 yd/s for 500 and never crossed the
/// radius it was testing (0794). Every `true` row therefore arms
/// [`super::ProbeFocusPlugin`] from `dev.rs`, which keeps the probe window un-occludable
/// (and, for a run that draws no pixels, parks it small in a corner — decision 1148).
///
/// A `false` row is a flag or a modifier: a `_AT`/`_STEP`/`_KEEP` rides its parent's
/// arming, a pricing lever changes *what* a run draws rather than *when*, and a state
/// machine that advances on wire replies (the char-create probe) has no clock to be robbed of.
/// Whether the variable schedules on elapsed real time, so it is only right at full frame
/// rate: macOS drops a covered window to ~1 fps, and such a run fires its script out of
/// order. A `true` row arms [`super::ProbeFocusPlugin`] from `dev.rs`, keeping the window
/// un-occludable; a modifier rides its parent's arming.
pub wall_clock: bool,
}
/// Every `WOW_PROBE*` variable the app reads. Grouped by the probe that owns each; the order is
/// the order `WOW_PROBE=list` prints.
/// Every `WOW_PROBE*` variable the app reads, grouped by owner, in `WOW_PROBE=list` order.
pub(crate) const PROBE_VARS: &[ProbeVar] = &[
// ── The variable itself, and the run shell every scripted probe rides ────────────────────
ProbeVar {
name: "WOW_PROBE",
purpose: "<name> — a value-dispatched live probe (see the names below); `list` prints this table",
// Five of the six values step a phase machine on `time.elapsed_secs` (melee's 3 s swing
// cadence, crossing/taxi/guardpoi's phases, castcancel's press-at-t); `partner` answers
// invites the frame they land, but rides the same variable.
// Every value but `list` and `partner` steps a phase machine on the wall clock.
wall_clock: true,
},
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, docs/METHOD.md) — 0794's
// throttled camera leg was exactly such a run.
// Fires on `ProbeClock`; a trace-only run sets it alone.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_PARK",
purpose: "corner|edge|off — where the un-occludable probe window sits, and whether it is pinned on top",
// A dial ON the occlusion defence itself, not a schedule.
// A dial on the occlusion defence itself, not a schedule.
wall_clock: false,
},
ProbeVar {
@@ -118,7 +85,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
ProbeVar {
name: "WOW_PROBE_HOVER",
purpose: "\"<frame>[;<frame>…]\" — sweep the real pointer across the named frames' centres, pressing nothing",
// Starts at `_AT`, one move per `_STEP` seconds, alternating on `_DUTY` — all wall seconds.
// Starts at `_AT`, one move per `_STEP` seconds, alternating on `_DUTY`, in wall seconds.
wall_clock: true,
},
ProbeVar {
@@ -128,7 +95,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
},
ProbeVar {
name: "WOW_PROBE_HOVER_STEP",
purpose: "<secs> — seconds per pointer move (default 0.25; ~0.016 sweeps at frame rate, the director's gesture)",
purpose: "<secs> — seconds per pointer move (default 0.25; ~0.016 sweeps at frame rate, like a real hand)",
wall_clock: false,
},
ProbeVar {
@@ -162,19 +129,17 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
purpose: "\"<chunk>\" — a Lua chunk evaluated after each drag whose string result is logged as the report",
wall_clock: false,
},
// ── The scripted controller inputs (decisions 0621/0653) ────────────────────────────────
// ── The scripted controller inputs ────────────────────────────────
ProbeVar {
name: "WOW_PROBE_LOOK",
purpose: "\"<deg_per_sec>@<start_s>:<duration_s>[;…]\" — the scripted mouse-turn: turn the avatar's aim at a rate for a while",
// `90°/s for 6 s` has to produce 540° whatever the frame rate did; integrates rate × dt
// between `at` and `until` on `ProbeClock`.
// Integrates rate times dt between `at` and `until` on `ProbeClock`.
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_PITCH",
purpose: "\"<deg>@<start_s>[:<deg_per_sec>][;…]\" — the scripted dive: aim a swimming avatar's nose up or down (+up)",
// A dive scripted to reach 30° by second 25 has to reach it at second 25, and it is the
// one script that can be a run's only actuator (a drifting swimmer needs no keys).
// Each dive target is due at its wall-clock second.
wall_clock: true,
},
ProbeVar {
@@ -194,7 +159,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
purpose: "1 — keep vsync ON in the FPS probe: it then measures the present ceiling the display grants this window",
wall_clock: false,
},
// ── The pricing levers and traces: change what a run draws or logs, never when ───────────
// ── The pricing levers and traces: what a run draws or logs, never when ─────────────────
ProbeVar {
name: "WOW_PROBE_UI_ONE_TEX",
purpose: "1 — pricing lever: split UI runs on state flags alone, ignoring texture identity (the draw-count ceiling an atlas would reach)",
@@ -211,8 +176,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
wall_clock: false,
},
// ── The `=1` live probes: each parks the body somewhere real and steps a phase machine ───
// Every one below reads `time.elapsed_secs_f64()` into a `since`/`now` phase machine —
// waits, settles and timeouts in wall seconds — so each arms the occlusion defence.
// Their waits, settles and timeouts are wall seconds, so each arms the occlusion defence.
ProbeVar {
name: "WOW_PROBE_BG_SAMPLES",
purpose: "n — how many 12 s census samples WOW_PROBE_BG takes inside the battleground (default 12; ~30 reaches vmangos's 5-minute premature finish, i.e. the end of a match)",
@@ -275,7 +239,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
},
ProbeVar {
name: "WOW_PROBE_BOOK",
purpose: "1 — teleport to the Old Town plaque and measure what the item-text reader costs per frame, closed vs open (B240)",
purpose: "1 — teleport to the Old Town plaque and measure what the item-text reader costs per frame, closed vs open",
wall_clock: true,
},
ProbeVar {
@@ -308,8 +272,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
ProbeVar {
name: "WOW_PROBE_CHARCREATE",
purpose: "\"<name>[,race,class,gender[,skin,face,hair,haircolor,facial]]\" — create (and delete) a character at select over the wire",
// A roster/result state machine: `AwaitingRoster` → create → result byte → delete →
// result byte, each step on the reply that arrives. No clock read anywhere in it.
// Each step advances on a wire reply; no clock is read.
wall_clock: false,
},
ProbeVar {
@@ -319,9 +282,8 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
},
];
/// The named values of `WOW_PROBE` itself, `(value, purpose)` — the one place they are listed.
/// `dev.rs` dispatches on the literals (one plugin per value) and `lib.rs` answers `list`
/// before any window opens; the test below keeps both in step with this table.
/// The named values of `WOW_PROBE`, `(value, purpose)`; `dev.rs` dispatches on them and `lib.rs`
/// answers `list`.
pub(crate) const PROBE_NAMES: &[(&str, &str)] = &[
("list", "print this table and exit, before any window opens"),
(
@@ -330,15 +292,15 @@ pub(crate) const PROBE_NAMES: &[(&str, &str)] = &[
),
(
"partner",
"the second client that says yes: auto-accept every group invite and duel challenge (0434/0637)",
"the second client that says yes: auto-accept every group invite and duel challenge",
),
(
"crossing",
"board a cross-continent boat and report the map seam surviving (0455)",
"board a cross-continent boat and report the map seam surviving",
),
(
"taxi",
"open the flight-master menu on the wire and ride Stormwind → Sentinel Hill to a verdict (0484)",
"open the flight-master menu on the wire and ride Stormwind → Sentinel Hill to a verdict",
),
(
"guardpoi",
@@ -350,18 +312,18 @@ pub(crate) const PROBE_NAMES: &[(&str, &str)] = &[
),
];
/// The variables whose presence arms the un-occludable probe window — the `wall_clock` column.
/// The variables whose presence arms the un-occludable probe window.
pub(crate) fn wall_clock_vars() -> impl Iterator<Item = &'static str> {
PROBE_VARS.iter().filter(|v| v.wall_clock).map(|v| v.name)
}
/// `WOW_PROBE=list` — print the registry, one variable per line, then the named values.
/// `WOW_PROBE=list`: prints the registry, one variable per line, then the named values.
pub(crate) fn print() {
println!("The probe fleet's environment (capture::probe_env, decision 2265 §A5).");
println!("The probe fleet's environment (capture::probe_env).");
println!(
"wall-clock: the variable schedules on elapsed real time and arms the un-occludable probe"
);
println!("window (ProbeFocusPlugin, decision 0906) — covered, such a run drops to ~1 fps and");
println!("window (ProbeFocusPlugin) — covered, such a run drops to ~1 fps and");
println!("executes the wrong script.");
println!();
println!("{:<26} {:<10} PURPOSE", "VARIABLE", "WALL-CLOCK");
@@ -380,17 +342,11 @@ pub(crate) fn print() {
mod tests {
use super::*;
/// This file's own path under `src/` — excluded from both scans, since its table and its
/// tests are the one place the literals are *supposed* to appear without being read.
/// This file's path under `src/`, excluded from both scans.
const SELF: &str = "capture/probe_env.rs";
/// **The table cannot drift from the code, in either direction** (decision 2265 §A5).
///
/// Every `"WOW_PROBE…"` string literal in the crate must be a row here — a variable the
/// fleet reads that the registry does not know is exactly the hand-maintained gap this
/// module replaced — and every row must be a literal somewhere other than this file, or the
/// registry is describing a variable nothing reads. String literals only: doc comments
/// write the backticked form and are skipped by line.
/// Every `"WOW_PROBE..."` string literal in the crate is a row, and every row is a literal
/// outside this file; comment lines are skipped.
#[test]
fn every_probe_variable_is_registered_and_every_registered_variable_is_read() {
let registered: Vec<&str> = PROBE_VARS.iter().map(|v| v.name).collect();
@@ -440,10 +396,8 @@ mod tests {
);
}
/// **`WOW_PROBE`'s named values are listed once.** Every `Ok("<name>")` the code compares
/// `WOW_PROBE`'s value against (the `dev.rs` plugin dispatch, the `lib.rs` `list` exit) must
/// be a `PROBE_NAMES` row, and every row must be dispatched on — so `WOW_PROBE=list` prints
/// the values that exist, not the ones someone remembered.
/// Every `Ok("<name>")` compared against `WOW_PROBE` is a `PROBE_NAMES` row, and every row
/// is dispatched on.
#[test]
fn every_dispatched_probe_name_is_listed_and_every_listed_name_is_dispatched() {
let listed: Vec<&str> = PROBE_NAMES.iter().map(|(n, _)| *n).collect();
@@ -514,8 +468,7 @@ mod tests {
out
}
/// The source with every comment line (`//`, `///`, `//!`) dropped — the scans below want
/// code, and a doc comment quoting a variable is not a read of it.
/// The source with every comment line dropped: a comment quoting a variable is not a read.
fn code_lines(text: &str) -> String {
text.lines()
.filter(|l| !l.trim_start().starts_with("//"))
@@ -523,8 +476,7 @@ mod tests {
.join("\n")
}
/// Every `"WOW_PROBE[A-Z0-9_]*"` string literal in the code — the whole literal, quote to
/// quote, so a variable named inside a longer message is not a match.
/// Every whole `"WOW_PROBE[A-Z0-9_]*"` string literal in the code, quote to quote.
fn probe_literals(text: &str) -> Vec<String> {
let code = code_lines(text);
let mut out = Vec::new();
@@ -551,8 +503,7 @@ mod tests {
let mut rest = code.as_str();
while let Some(i) = rest.find(needle) {
let after = &rest[i + needle.len()..];
// The comparison sits right after the read: `.as_deref() == Ok("…")`. Bound the
// look-ahead so a bare `is_ok()` gate is not paired with some later `Ok("…")`.
// Bounded look-ahead, so a bare `is_ok()` gate is not paired with a later `Ok("...")`.
let window = &after[..after.len().min(48)];
if let Some(j) = window.find("Ok(\"") {
let value = &after[j + 4..];
+87 -202
View File
@@ -1,108 +1,40 @@
//! The GM trouble-ticket live probe (`WOW_PROBE_GMTICKET=1`) — decision 1673's end-to-end
//! instrument: log in, and drive the whole five-opcode ticket wire through the **live Lua VM's own
//! bindings** (`GetGMStatus`, `DeleteGMTicket`, `GetGMTicket`, `NewGMTicket`, `UpdateGMTicket`),
//! observing every answer where the Help window observes it — as an `UPDATE_TICKET` /
//! `UPDATE_GM_STATUS` event fired at a real frame. One
//! `PROBE_GMTICKET: <step> PASS/FAIL/SKIP <detail>` line per step, then a final
//! `PROBE_GMTICKET: DONE pass=<n> fail=<m>`. Modeled on [`super::probe_binder`] and
//! [`super::probe_bank`] — same phase machine, same trace style, same self-terminating exit
//! ([`super::probes::ProbeExitPlugin`]'s pattern), same live-VM observation idiom (a small Lua hook
//! appending to a probe table, read back with `script.eval`).
//! The GM ticket live probe (`WOW_PROBE_GMTICKET=1`): drives the five ticket opcodes through the
//! live Lua VM's bindings (`GetGMStatus`, `DeleteGMTicket`, `GetGMTicket`, `NewGMTicket`,
//! `UpdateGMTicket`) and observes each answer as the Help window does, as an `UPDATE_TICKET` or
//! `UPDATE_GM_STATUS` event. Logs one `PROBE_GMTICKET: <step> PASS/FAIL/SKIP <detail>` line per
//! step, then `PROBE_GMTICKET: DONE pass=<n> fail=<m>`, and exits. Non-combat and sends no GM
//! command; the switches are `docs/CONTRIBUTING.md`, "Running it unattended".
//!
//! ## Why unit tests cannot close this feature
//! vmangos answers several refusals with silence (`GMTicketHandler.cpp:88-113`): no packet for a
//! create when the queue is off, the player is under `GMTickets.MinLevel` or the category is
//! `>= GMTICKET_MAX` (11), nor for a delete with no ticket (`:73-86`).
//!
//! 1673 ships 23 unit tests, byte-exact wire goldens and a DBC oracle, and **not one of them can
//! tell whether vmangos accepts the packet**. The server answers several refusals with *silence*:
//! `HandleGMTicketCreateOpcode` returns with no packet at all when the queue is off, when the
//! player is under `GMTickets.MinLevel`, or when the category is `>= GMTICKET_MAX` (11), and
//! `HandleGMTicketDeleteTicketOpcode` likewise when there is no ticket (vmangos
//! `Handlers/GMTicketHandler.cpp`, read this session). A create the server drops on the floor is
//! byte-identical, from the client's side, to one that worked. Only a live round trip settles it.
//! The steps:
//! 1. queue: `GetGMStatus()` must fire `UPDATE_GM_STATUS` with 1 (`GMTickets.Enable` defaults on,
//! `World.cpp:684`); a 0 is the queue switched off, and the rest SKIPs.
//! 2. clean: `DeleteGMTicket()` then `GetGMTicket()`, expecting `arg1 == 0`; the get's answer is
//! waited on, since a delete with no ticket gets none.
//! 3. create: `NewGMTicket(4, <unique text>)` then `GetGMTicket()`, expecting category 4 and the
//! text exactly, which proves the create body's layout.
//! 4. db: always SKIP. The client never reads map and position back, so the probe prints the row
//! it expects (`db-expect`, with a `drift=` bound) and the row is checked by hand.
//! 5. edit: `UpdateGMTicket(4, <second text>)` then `GetGMTicket()`, expecting the new text
//! exactly, which proves the category byte on `CMSG_GMTICKET_UPDATETEXT` (cmangos-classic reads
//! a bare cstring there).
//! 6. abandon: `DeleteGMTicket()` then `GetGMTicket()`, expecting `arg1 == 0` again.
//!
//! ## What each step can and cannot conclude
//! Lua's calls reach the wire in call order, so each step only waits on the server's round trip.
//! Answers are not correlated to asks: a GM command, vmangos's post-delete `SendTicket(nullptr)`
//! and the engine's own re-ask on create-ok and update-ok (reference `0x5e4479`) all fire
//! `UPDATE_TICKET` unasked, so each step takes any matching answer after its baseline, and matches
//! a unique text in steps 3 and 5.
//!
//! 1. **queue** — `GetGMStatus()`, then `UPDATE_GM_STATUS` carrying **1**. `GMTickets.Enable`
//! defaults to `true` (`World.cpp:684`), so 1 is the expected answer; no event at all means the
//! opcode round trip is dead and is a FAIL. A `0` is not a client defect — it is the queue
//! genuinely switched off, and every later step would then be answered with silence, so the
//! probe SKIPs the rest rather than manufacturing failures out of a server setting.
//! 2. **clean** — `DeleteGMTicket()`, then `GetGMTicket()`, expecting `arg1 == 0`. Clears a
//! leftover ticket from an earlier run *and* proves the "no ticket" answer decodes
//! (`GMTICKET_STATUS_DEFAULT`, a 4-byte body). **The trap:** delete-with-no-ticket is answered
//! with nothing at all, so this waits on the `GetGMTicket` answer that follows, never on a
//! delete response.
//! 3. **create** — `NewGMTicket(4, <unique text>)` (4 = Item, `GMTicketCategory.dbc` id 4 =
//! vmangos `GMTICKET_ITEM`), then `GetGMTicket()`, expecting `arg1 == 4` and `arg2` **equal** to
//! the text sent. The load-bearing step: it proves the `u8` category, the map/position block,
//! the text and the trailing `"Reserved for future use"` cstring are laid out the way vmangos
//! reads them. A category widened to `u32` would shift the map id and the position three bytes
//! down; the server would file the ticket at a garbage spot or reject the packet outright.
//! 4. **db** — the row itself. **A two-part check, and the probe only does the first half**: the
//! client never reads map/position back, so the echo in step 3 cannot prove they landed. The
//! probe *prints* what it sent (`PROBE_GMTICKET: db-expect …`) and the operator runs the SQL
//! after the run and compares. This step therefore always reports SKIP — it is not a pass the
//! probe is entitled to claim. The line carries a `drift=` figure: how far the body could have
//! moved between the position sample and the frame the packet was stamped (see [`REST_EPS`]).
//! `drift=0.000` means the position in the line is exact.
//! 5. **edit** — `UpdateGMTicket(4, <second unique text>)`, then `GetGMTicket()`, expecting `arg2`
//! **equal** to the new text. This is the step that proves the **category byte** on
//! `CMSG_GMTICKET_UPDATETEXT` — the emulator fork 1673 records, where cmangos-classic reads a
//! bare cstring and would swallow our category byte as the first character of the text. The
//! comparison is equality, never `contains`, because a stray leading control byte is exactly
//! the failure this step exists to catch.
//! 6. **abandon** — `DeleteGMTicket()`, then `GetGMTicket()`, expecting `arg1 == 0` again.
//! vmangos's abandon is `CloseTicket`, which sets `closed_by` and keeps the row
//! (`GMTicketMgr.cpp:401-410`); an edit replaces `message` and the type but keeps the create's map
//! and position (`GMTicketHandler.cpp:59-60`).
//!
//! ## Two facts about waiting that this probe is built around
//!
//! **The wait is for the SERVER, never for the drain.** Lua's calls reach the wire in call order:
//! [`crate::ui_gm_ticket`]'s `drain_gm_ticket` walks ONE ordered intent queue
//! (`take_gm_ticket_intents`), so `DeleteGMTicket(); GetGMTicket()` in a single chunk arrives
//! delete-then-get. It did not always — the drain used to walk a counter per verb and put every
//! ask ahead of every write and delete, which inverted exactly that pair and made the get answer
//! with the *pre*-delete state. This probe is what surfaced that (driving the bindings directly is
//! what a third-party addon does and what no shipped-window test exercises); decision 1673's "One
//! ordered queue, not a counter per verb" records the fix. What still has to be waited for is the
//! **round trip**: each step separates its write from the read-back by [`WRITE_GAP_SECS`] and then
//! waits up to [`ANSWER_TIMEOUT_SECS`] for an answer that matches. The gap is no longer a
//! correctness crutch; it keeps one exchange per trace line, which is what makes a failing step
//! readable.
//!
//! **Answers are not correlated to asks, so nothing here waits for "the next event".** Three
//! sources put an `UPDATE_TICKET` on the stream that this probe never explicitly asked for: a GM's
//! `.ticket viewid`/`escalate`/`complete`; vmangos's own delete handler (`SendTicket(this,
//! nullptr)`, right after the delete response); and the **engine's own re-ask**, which fires on
//! every create-ok (2) and update-ok (4) response (`GmTicketState::note_write_landed`, the
//! reference's `0x5e4479`). So after step 3's `NewGMTicket` an answer arrives *before* the probe's
//! own `GetGMTicket()` — expected, not an anomaly. Each step latches the observed event count as a
//! baseline and then waits for **any** answer after it that matches the expectation, failing only
//! at the timeout and printing every answer it did see. Matching on a *unique* text for the two
//! load-bearing steps is what keeps that from being a weaker assertion than "the next one".
//!
//! ## What the DB row looks like afterwards
//!
//! vmangos's abandon is `CloseTicket`, which sets `closed_by` and `SaveToDB()`s — it does **not**
//! delete the row (`GMTicketMgr.cpp:401-410`) — so step 6 leaves the row in place for the operator
//! to read. `HandleGMTicketUpdateTextOpcode` calls `SetTicketType(packet.type)` as well as
//! `SetMessage`, so after step 5 the row's `message` is the **edited** text while `map`/
//! `position_*` still carry the create's values (the row is REPLACEd in place). Both `db-expect`
//! lines say which is which.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
//! WOW_PROBE_GMTICKET=1 cargo run -q -p benilla
//! ```
//! (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
//! timing measurement, so an occluded window costs wall clock and no correctness.
//!
//! Every step SKIPs with a note rather than FAILing for an environmental problem (no UI VM in this
//! build, the queue switched off server-side, a binding that would not run). A genuine wrong value
//! is a FAIL.
//! An environmental problem (no UI VM, the queue off, a binding that will not run) SKIPs; only a
//! wrong value FAILs.
use bevy::prelude::*;
@@ -113,58 +45,41 @@ use super::probes::ProbeClock;
use crate::net::SelfPlayer;
use crate::player::Player;
/// `GMTicketCategory.dbc` id 4 = "Item" (decision 1673's table), which is vmangos's
/// `GMTICKET_ITEM = 4` (`SharedDefines.h:1779`) — the same number on both sides, which is the
/// point: the id is the wire value, not a list index.
/// `GMTicketCategory.dbc` id 4, "Item", which is vmangos's `GMTICKET_ITEM`
/// (`SharedDefines.h:1779`): the id is the wire value, not a list index.
const CATEGORY_ITEM: u32 = 4;
/// `SMSG_GMTICKETSYSTEMSTATUS`'s "the queue is taking tickets" (`GMTICKET_QUEUE_STATUS_ENABLED`).
///
/// The field is **signed** (`GmTicketState::queue_status`, byte-verified: the reference loads it
/// with `fild dword`), so `-1` — the window's "queue down, and say so" arm — is a value this probe
/// can legitimately read back. vmangos only ever sends 0 or 1.
/// `SMSG_GMTICKETSYSTEMSTATUS`'s `GMTICKET_QUEUE_STATUS_ENABLED`. The field is signed (the
/// reference fires it at `0x5e4689` through `%d`, a `fild dword` at `0x704fa6`), so the window's
/// -1 arm is readable; vmangos sends 0 or 1.
const QUEUE_ENABLED: f64 = 1.0;
/// The `UPDATE_TICKET` `arg1` that means "you have no ticket" — the window's whole else-branch
/// hangs on `arg1 and arg1 ~= 0`.
/// The `UPDATE_TICKET` `arg1` for no ticket; the Help window tests `arg1 and arg1 ~= 0`.
const NO_TICKET: f64 = 0.0;
/// Settle before the first ask: lets the body land, the world stream, and any login-time
/// `GetGMTicket()` the shipped Help window fires drain and answer before a baseline is latched.
/// Settle before the first ask, so the Help window's login-time `GetGMTicket()` is answered before
/// a baseline is latched.
const SETTLE_SECS: f64 = 3.0;
/// The gap between a write/delete and the `GetGMTicket()` that reads it back.
///
/// Not a correctness requirement — the drain preserves Lua's call order (module doc) — but a
/// readability one: it keeps one exchange per trace line, so a step that fails names the exchange
/// that failed rather than a batch of them.
/// The gap between a write and its `GetGMTicket()` read-back; the drain keeps call order, so it
/// only keeps one exchange per trace line.
const WRITE_GAP_SECS: f64 = 1.0;
/// How long a step waits for a matching answer before calling it. Generous on purpose: a starved
/// (occluded) window polls at ~1 fps and a timeout tight enough to trip on that would report a
/// FAIL about the wire, which is the one thing an instrument must never do.
/// How long a step waits for a matching answer; generous, as an occluded window polls at ~1 fps.
const ANSWER_TIMEOUT_SECS: f64 = 20.0;
/// **The body must be at rest before the ticket is filed.** A ticket records *where you are*, and
/// the first live run of this probe filed one while the body was still falling: it entered the
/// world at z ≈ 94, the preflight banner read 82, and it was through 60 a second later — 21 yd of
/// travel across the create. The row happened to match anyway (the drain sampled the same frame),
/// but "happened to" is not a property an instrument may rest step 4 on, so the create now waits
/// for the body to stop moving. Movement under [`REST_EPS`] yd for [`REST_HOLD_SECS`] counts as
/// stopped.
/// A ticket records the body's position, so the create waits for it to rest: under this many yd
/// of movement for [`REST_HOLD_SECS`].
const REST_EPS: f32 = 0.02;
const REST_HOLD_SECS: f64 = 0.5;
/// The cap on that wait. Timing out is not a failure — the probe files anyway and says the
/// position may be off, because a body that genuinely cannot come to rest (a lift, a swim, a
/// treadmill of terrain streaming) still has a working ticket wire to test.
/// The cap on that wait; past it the probe files anyway and flags the position as approximate.
const REST_TIMEOUT_SECS: f64 = 20.0;
/// How far the body may have travelled between the position sample and the frame the drain stamps
/// the packet, before the db-expect line stops being trustworthy (yd, WoW space).
/// How far (yd) the body may move between the position sample and the frame the drain stamps the
/// packet before the db-expect position is flagged.
const STAMP_DRIFT_EPS: f32 = 0.05;
/// 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.
/// Step 4's query against the server's `characters` database, printed for a manual check.
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;";
@@ -178,26 +93,24 @@ impl Plugin for ProbeGmTicketPlugin {
}
}
/// The probe's phase machine plus what it discovered along the way (the bank/binder probes' shape:
/// a `Copy` phase snapshotted out of the resource each tick, so an arm can mutate `probe` freely).
/// The probe's phase machine and what it discovered along the way.
#[derive(Resource, Default)]
struct GmTicketProbe {
phase: Phase,
/// The text step 3 filed — unique per run, so no stale answer can satisfy step 3 by accident.
/// The text step 3 filed, unique per run so no stale answer satisfies it.
create_text: String,
/// The text step 5 edited it to.
edit_text: String,
/// Map and WoW-space position sampled at the moment `NewGMTicket` was called — what the drain
/// stamps into `CMSG_GMTICKET_CREATE` in that frame or the next, and what the DB row must
/// carry.
/// Map and WoW-space position sampled at the `NewGMTicket` call, which the drain stamps into
/// `CMSG_GMTICKET_CREATE` that frame or the next.
create_map: u32,
create_pos: [f32; 3],
/// How far the body had travelled by the frame after the create call — the honest bound on
/// how wrong the db-expect position could be (`None` until that frame has been seen).
/// How far the body had moved by the frame after the create call: the db-expect position's
/// error bound.
stamp_drift: Option<f32>,
passes: u32,
fails: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
/// Latched once [`Phase::Done`] has fired its exit.
exited: bool,
}
@@ -205,7 +118,7 @@ struct GmTicketProbe {
enum Phase {
#[default]
Wait,
/// Hook installed; settling before the first ask (module doc).
/// Hook installed; settling before the first ask.
Settling {
since: f64,
},
@@ -223,8 +136,8 @@ enum Phase {
since: f64,
baseline: usize,
},
/// Waiting for the body to stop moving before the ticket is filed ([`REST_EPS`]) — `last` is
/// the previous sample and `still_since` when it was last seen to move.
/// Waiting for the body to rest; `last` is the previous sample, `still_since` when it last
/// moved.
Rest {
since: f64,
last: [f32; 3],
@@ -243,7 +156,7 @@ enum Phase {
EditGap {
since: f64,
},
/// `GetGMTicket()` called; waiting for the *new* text (step 5b).
/// `GetGMTicket()` called; waiting for the new text (step 5b).
EditGet {
since: f64,
baseline: usize,
@@ -260,10 +173,8 @@ enum Phase {
Done,
}
/// The `UPDATE_TICKET` answers the live VM has seen, newest last, as `(arg1, arg2)` — the category
/// (0 for "no ticket") and the description. `arg2` is recorded as `""` whenever `arg1` is 0, since
/// the no-ticket fire carries a single argument and reading a second one would be reading whatever
/// the global happened to hold.
/// The `UPDATE_TICKET` answers seen, newest last, as `(category, text)`. The text is `""` when the
/// category is 0: that fire carries one argument, and `arg2` would be stale.
fn ticket_answers(script: &UiScript) -> Vec<(f64, String)> {
let cats = script
.eval::<Vec<f64>>("return ProbeGmTicketCat or {}")
@@ -281,10 +192,7 @@ fn status_answers(script: &UiScript) -> Vec<f64> {
.unwrap_or_default()
}
/// The first answer after `baseline` that satisfies `want` — the module doc's match-or-timeout
/// rule. Answers are not correlated to asks, so "the next event" is not a safe read; "any answer
/// after the baseline that matches" is, and the timeout is what turns a never-matching stream into
/// a FAIL that prints everything it saw.
/// The first answer after `baseline` that satisfies `want`; answers are not correlated to asks.
fn matching<T>(answers: &[T], baseline: usize, want: impl Fn(&T) -> bool) -> Option<usize> {
answers
.iter()
@@ -294,9 +202,7 @@ fn matching<T>(answers: &[T], baseline: usize, want: impl Fn(&T) -> bool) -> Opt
.map(|(i, _)| i)
}
/// A unique-per-run ticket text, so no stale or unsolicited answer can satisfy steps 3/5 by
/// accident. Seconds resolution is plenty — two runs cannot start in the same second and reach the
/// same step, and the suffix distinguishes the two texts within a run.
/// A ticket text unique per run (to the second); the suffix tells a run's two texts apart.
fn unique_text(suffix: &str) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -318,15 +224,14 @@ fn gm_ticket_probe(
return; // not in-world yet
}
let Some(script) = script else {
return; // no UI VM this build (headless net-only) — nothing this probe can drive
return; // no UI VM in a headless build
};
let now = time.elapsed_secs_f64();
let phase = probe.phase;
match phase {
Phase::Wait => {
// The observation channel, installed before anything is asked so it is live for every
// answer including the unsolicited ones (the binder probe's exact pattern).
// The hook goes in before any ask, so it sees the unsolicited answers too.
if let Err(e) = script.run(
r#"
if not ProbeGmTicketHooked then
@@ -353,9 +258,7 @@ fn gm_ticket_probe(
end
"#,
) {
// Without the hook nothing can be observed, and every step would then FAIL for a
// reason that has nothing to do with the wire. That is the one failure mode an
// instrument must never produce, so it stops here instead.
// Without the hook nothing is observable, so stop rather than FAIL every step.
warn!(
"PROBE_GMTICKET: SKIP (0 hook) — the UPDATE_TICKET/UPDATE_GM_STATUS hook would \
not install in the live VM: {e}. Nothing can be observed, so no step below \
@@ -382,7 +285,7 @@ fn gm_ticket_probe(
};
}
// Step 1 — the queue status. No event at all means the round trip is dead.
// Step 1, the queue status: no event at all means the round trip is dead.
Phase::Queue { since, baseline } => {
let seen = status_answers(&script);
let Some(i) = matching(&seen, baseline, |_| true) else {
@@ -424,8 +327,8 @@ fn gm_ticket_probe(
probe.phase = Phase::CleanGap { since: now };
}
// Step 2 — the clean slate. The delete is deliberately NOT waited on: with no ticket
// vmangos answers it with nothing at all.
// Step 2, the clean slate. The delete is not waited on: with no ticket vmangos sends
// nothing.
Phase::CleanGap { since } => {
if now - since < WRITE_GAP_SECS {
return;
@@ -465,8 +368,7 @@ fn gm_ticket_probe(
}
}
// The rest gate: a ticket records where you are, so file it from a body that has stopped
// moving (module doc — the first live run filed one mid-fall).
// The rest gate: a ticket records the position, so file it from a body at rest.
Phase::Rest {
since,
last,
@@ -507,13 +409,10 @@ fn gm_ticket_probe(
probe.phase = Phase::CreateGap { since: now };
}
// Step 3 — the load-bearing one: the create body's layout, proven by the echo.
// Step 3: the create body's layout, proven by the echo.
Phase::CreateGap { since } => {
// The first tick after the create call is the last frame in which the drain can still
// stamp the packet, so this is the whole window in which the body could move between
// the sample and the wire. Measuring it here — rather than a second later, at the
// db-expect print — is the difference between an honest bound and crying wolf at a
// body that simply kept falling after the packet had already gone.
// The first tick after the create call is the last frame the drain can stamp the
// packet in, so the drift is measured here and not at the later db-expect print.
if probe.stamp_drift.is_none() {
let drift = distance(bevy_to_wow(player.pos), probe.create_pos);
probe.stamp_drift = Some(drift);
@@ -592,7 +491,7 @@ fn gm_ticket_probe(
}
}
// Step 5 — the edit, and with it the category byte on CMSG_GMTICKET_UPDATETEXT.
// Step 5: the edit, and with it the category byte on CMSG_GMTICKET_UPDATETEXT.
Phase::EditGap { since } => {
if now - since < WRITE_GAP_SECS {
return;
@@ -609,8 +508,7 @@ fn gm_ticket_probe(
Phase::EditGet { since, baseline } => {
let seen = ticket_answers(&script);
let want = probe.edit_text.clone();
// Equality, never `contains`: a leading control byte in the stored text is precisely
// the cmangos-style failure this step exists to catch (decision 1673's divergence).
// Equality, never `contains`: a stray leading byte is the failure this step catches.
if let Some(i) = matching(&seen, baseline, |(_, t)| *t == want) {
let (cat, text) = &seen[i];
info!(
@@ -635,7 +533,7 @@ fn gm_ticket_probe(
{ANSWER_TIMEOUT_SECS}s. Wanted arg2 == {want:?}; answers seen after the \
baseline: {:?}. leading-byte-swallowed={stray} — if that is true, the server \
read our category byte as the first character of the text, which is the \
cmangos-classic behaviour decision 1673 records.",
cmangos-classic behaviour.",
&seen[baseline.min(seen.len())..]
);
probe.fails += 1;
@@ -643,8 +541,7 @@ fn gm_ticket_probe(
}
}
// Step 6 — abandon. vmangos keeps the row (CloseTicket → closed_by + SaveToDB), so the
// operator's SQL still finds it afterwards.
// Step 6, abandon. vmangos's `CloseTicket` keeps the row, so step 4's query still finds it.
Phase::AbandonGap { since } => {
if now - since < WRITE_GAP_SECS {
return;
@@ -690,9 +587,7 @@ fn gm_ticket_probe(
"PROBE_GMTICKET: DONE pass={} fail={}",
probe.passes, probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// AppExit plus a hard-exit backstop, so a teardown hang cannot keep the account held.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
@@ -703,10 +598,8 @@ fn gm_ticket_probe(
}
}
/// Run one binding in the live VM, or SKIP the whole probe with the reason.
///
/// A binding that will not run is environmental (no VM, a broken chunk) — never a wire verdict —
/// so it must not print a FAIL. Returns `false` once the probe has been parked in [`Phase::Done`].
/// Runs one binding in the live VM, or SKIPs the whole probe and returns `false`: a binding that
/// will not run is environmental, never a wire verdict.
fn run_or_skip(probe: &mut GmTicketProbe, script: &UiScript, step: &str, chunk: &str) -> bool {
match script.run(chunk) {
Ok(()) => {
@@ -724,13 +617,8 @@ fn run_or_skip(probe: &mut GmTicketProbe, script: &UiScript, step: &str, chunk:
}
}
/// Print step 4's half of the check: exactly what the DB row must carry.
///
/// The position is the one sampled at the `NewGMTicket` call — what the drain stamps into the
/// packet, in that frame or the next. How much the body could have moved inside that window is
/// measured once, at the frame after the call, and reported here as `drift=`; a value at or under
/// [`STAMP_DRIFT_EPS`] means the line is exact. The map id and `ticket_type` are exact regardless.
/// **Nothing here is a PASS** — the row is the operator's to read.
/// Prints what step 4's DB row must carry. `drift=` bounds the position's error; at or under
/// [`STAMP_DRIFT_EPS`] it is exact, and the map id and `ticket_type` are always exact.
fn report_db_expectation(probe: &GmTicketProbe, when: &str, message: &str) {
let [x, y, z] = probe.create_pos;
info!(
@@ -750,9 +638,8 @@ fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
mod tests {
use super::*;
/// The match-or-timeout rule: a step accepts **any** answer after its baseline that matches,
/// because unsolicited answers (a GM's `.ticket viewid`, vmangos's own post-delete push) share
/// the stream with the solicited ones. It must never accept one from *before* the baseline.
/// Unsolicited answers share the stream, so any match after the baseline counts, never one
/// before it.
#[test]
fn a_step_matches_after_its_baseline_and_never_before_it() {
let answers = vec![
@@ -764,14 +651,12 @@ mod tests {
assert_eq!(matching(&answers, 0, |(_, t)| t == "mine"), Some(1));
// Baseline past the first match: the later identical answer is the one taken.
assert_eq!(matching(&answers, 2, |(_, t)| t == "mine"), Some(3));
// Nothing after the baseline matches — the caller's timeout is what turns this into a
// FAIL, and it must not be papered over by an earlier answer.
// Nothing after the baseline matches, and an earlier answer must not count.
assert_eq!(matching(&answers, 4, |(_, t)| t == "mine"), None);
assert_eq!(matching(&answers, 2, |(c, _)| *c == NO_TICKET), Some(2));
}
/// The two texts within a run are distinct — step 5's assertion is "the text CHANGED to this",
/// and it would be vacuous if the edit reused the create's string.
/// Step 5 asserts the text changed, which needs the edit's text to differ from the create's.
#[test]
fn the_create_and_edit_texts_are_distinct() {
assert_ne!(unique_text(""), unique_text(" edited"));
+45 -89
View File
@@ -1,71 +1,33 @@
//! The GameObject-questgiver live probe (`WOW_PROBE_GOQUEST=1`) — the instrument for *"wanted
//! posters and quest objects are never status-queried"*, and for the answer that question turned
//! out to have (decision 1872).
//! The GameObject-questgiver live probe (`WOW_PROBE_GOQUEST=1`). The reference asks about a
//! quest-giving GameObject as it does a creature: the sweep sends `CMSG_QUESTGIVER_STATUS_QUERY`
//! for any GameObject with `GAMEOBJECT_FLAGS` bit 2 whose reaction toward us is `> 1`. It then
//! refuses the answer: its handler `0x5dc9f0` resolves the guid with typemask 8, a GameObject's
//! `0x21` misses bit 3, and the packet dies at `0x5dca2f`. Real 1.12 captures carry no GameObject
//! status at all, but vmangos answers one (`TYPEMASK_CREATURE_OR_GAMEOBJECT`,
//! `QuestHandler.cpp:40`), so without the refusal a wanted poster would wear a `!`. The probe tells
//! "asked and refused" from "never asked" through [`QuestGiver::refused_for`], keyed by guid since
//! one drain answers several quest objects.
//!
//! A quest-giving **GameObject** — a wanted poster, a half-eaten body, a suspicious barrel — is a
//! questgiver on the wire exactly as a creature is, and the reference client asks about it: the
//! sweep callback tests typemask bit 5 and sends `CMSG_QUESTGIVER_STATUS_QUERY` for any GameObject
//! carrying `GAMEOBJECT_FLAGS` bit 2 whose reaction toward us is `> 1`. benilla asked about no
//! GameObject at all, which is a real wire gap.
//! Four readings:
//!
//! But **the answer is refused, and nothing is ever drawn**: the reference's handler `0x5dc9f0`
//! resolves the GUID with typemask 8, a GameObject's `0x21` misses bit 3, and the packet dies at
//! `0x5dca2f`. And the real 1.12 service never sent one anyway — zero GameObject GUIDs across the
//! 1292 in the genuine sniff corpus. **vmangos does send one** (`TYPEMASK_CREATURE_OR_GAMEOBJECT`,
//! `QuestHandler.cpp:41`), so on our server the refusal is load-bearing rather than theoretical:
//! without it a wanted poster would wear a gold `!` the reference client leaves bare.
//! 1. LOW: at level [`LOW_LEVEL`], below the quest's `MinLevel`, the server answers 1
//! (`UNAVAILABLE`) and we refuse it.
//! 2. HIGH: past `MinLevel` the answer changes to 5 (`AVAILABLE`), so it is a live query.
//! 3. NO STATUS: `QuestGiver::status()` for the poster stays `None` every frame; the marker layer
//! and the minimap dot both draw from it.
//! 4. UNIT CONTROL: a creature questgiver in the same scene still gets and keeps its status.
//!
//! That makes this probe's job the three-way distinction a unit test cannot make at the real
//! object: **the query went out**, **the answer came back**, and **we refused it** — where "never
//! asked" and "asked and correctly refused" both read as an empty status map. The refusal readout
//! ([`QuestGiver::refused_for`]) is what separates them, and an arriving status can only exist if
//! our query did. It is keyed by guid on purpose: Goldshire has several quest objects in view and
//! the server answers all of them in one drain, so a single last-writer slot reads as whichever
//! object archetype order happened to put last (it read as a regression exactly once, on a run
//! where nothing about the behaviour had changed).
//! `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]` aims it elsewhere; the default is the Goldshire wanted
//! poster (`gameobject` 26843, template 68, type 2, flags 4, quest 176 with `MinLevel` 5 and
//! `QuestLevel` 11), a single unpooled spawn, with questgiver creatures nearby for the control.
//! Each window sets the level and bumps the re-ask epoch to force a sweep, the only way a
//! GameObject guid reaches the wire; the starting level is restored on exit. It logs
//! `PROBE_GOQUEST:` lines and exits; the switches are `docs/CONTRIBUTING.md`, "Running it
//! unattended".
//!
//! Four readings, in order, and the last two are the controls that make it a regression test:
//!
//! 1. **LOW** — at level [`LOW_LEVEL`], below the quest's own `MinLevel`: the server must answer
//! `1` (`UNAVAILABLE`, the grey `!` a *creature* would wear) and we must refuse it.
//! 2. **HIGH** — after a ding past `MinLevel`: the answer must change to `5` (`AVAILABLE`). A probe
//! that only ever saw one value could not tell a live query from a single stale packet.
//! 3. **NO STATUS** — `QuestGiver::status()` for the poster stays `None` throughout, sampled every
//! frame rather than at the windows. The marker layer and the minimap dot both draw from exactly
//! that map, so an empty entry *is* "no `!`, no dot".
//! 4. **THE UNIT CONTROL** — a creature questgiver in the same scene still gets and keeps its own
//! status. This is the half that must not change, and it is what would catch a refusal written
//! one bit too wide.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
//! WOW_PROBE_GOQUEST=1 cargo run -q -p benilla
//! ```
//! (the 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
//! with a 900 s respawn, so it is there every run — unlike the chest probe's first target, which
//! was a spawn-pool member and read "missing" most of the time (decision 1471's trap). Goldshire
//! also supplies the unit control (Marshal Dughan and the innkeeper are both questgiver-flagged),
//! which is why both halves can be read at one spot.
//!
//! Each window forces its own sweep, because that is the *only* way a GameObject GUID reaches the
//! wire: it sets the level (the reference's own `UNIT_FIELD_LEVEL` watch, and what makes the answer
//! change) and then bumps the packet epoch, so the window sweeps even if the character already
//! happened to be standing at the level the window wants. The starting level is restored on exit.
//!
//! **One trap the scan line will show you:** every probe character is permanently in GM mode
//! (`characters.extra_flags` bit `0x1`), and vmangos ORs `target->IsGameMaster()` into a
//! GameObject's `IsActivateToQuest` (`Object.cpp`) — so `GAMEOBJECT_DYN_FLAGS` reads `0x1` on
//! every quest object regardless of whether the character could actually take the quest. The
//! *dialog status* is computed without any GM term, so the windows below are unaffected; but do
//! not read the dyn-flag as "this quest is available to me", and do not gate a query on it.
//!
//! Grep `PROBE_GOQUEST:` for the verdict; the probe self-exits when it lands.
//! vmangos ORs `IsGameMaster()` into a GameObject's `IsActivateToQuest` (`Object.cpp:652`), so in
//! GM mode `GAMEOBJECT_DYN_FLAGS` reads `0x1` on every quest object; the dialog status has no GM
//! term, so the readings are unaffected.
use bevy::prelude::*;
@@ -79,15 +41,15 @@ use crate::ui_quest::QuestGiver;
/// The probe's default object: the `Wanted Poster` outside the Goldshire inn (`gameobject` 26843,
/// template 68, quest 176). `[x, y, z, map]`.
const POSTER_AT: [f32; 4] = [-9668.23, 683.39, 36.33, 0.0];
/// `GAMEOBJECT_TYPE_QUESTGIVER` — the strategy type a wanted poster carries.
/// `GAMEOBJECT_TYPE_QUESTGIVER`, the type a wanted poster carries.
const GO_TYPE_QUESTGIVER: i32 = 2;
/// `GAMEOBJECT_FLAGS` bit 2 — the reference's whole GameObject query gate (`0x5eb0f2 shr eax,0x2`).
/// `GAMEOBJECT_FLAGS` bit 2, the reference's GameObject query gate (`0x5eb0f2`).
const GO_FLAG_INTERACT_COND: u32 = 0x4;
/// `UNIT_NPC_FLAGS` questgiver bit — how the unit control names itself.
/// `UNIT_NPC_FLAGS` questgiver bit, how the unit control is found.
const NPC_FLAG_QUESTGIVER: u32 = 0x2;
/// The two levels the two windows are read at, chosen against quest 176's own `MinLevel` 5 and
/// `QuestLevel` 11: below it the server answers `UNAVAILABLE`, above it `AVAILABLE` (and not
/// `CHAT`, which would need `level > QuestLevel + Quests.LowLevelHideDiff`).
/// The two windows' levels, against quest 176's `MinLevel` 5 and `QuestLevel` 11: below it the
/// server answers `UNAVAILABLE`, above it `AVAILABLE` (not `CHAT`, which needs
/// `level > QuestLevel + Quests.LowLevelHideDiff`).
const LOW_LEVEL: u32 = 2;
const HIGH_LEVEL: u32 = 8;
/// `DialogStatus` ids the verdict is written in (the keys of the status map `0x80c454`).
@@ -96,8 +58,7 @@ const STATUS_AVAILABLE: u32 = 5;
const SETTLE_SECS: f64 = 5.0;
const SCAN_TIMEOUT_SECS: f64 = 25.0;
/// How long a window waits for the refused answer. The query goes out on the sweep the window
/// itself forces; nothing arriving in this long means it was never sent.
/// How long a window waits for the refused answer; nothing arriving means it was never sent.
const STATUS_TIMEOUT_SECS: f64 = 15.0;
/// How long a `.levelup` is given to come back down the wire as a `UNIT_FIELD_LEVEL` change.
const LEVEL_TIMEOUT_SECS: f64 = 15.0;
@@ -118,9 +79,9 @@ struct GoQuestProbe {
phase: Phase,
/// The poster's guid, once the scan finds it.
poster: Option<u64>,
/// The unit control's guid — a questgiver-flagged creature in the same scene.
/// The unit control's guid, a questgiver-flagged creature in the same scene.
control: Option<u64>,
/// The level the character was at when the probe started — restored before it exits.
/// The level the character was at when the probe started, restored before it exits.
start_level: Option<u32>,
/// The refused status read in each window.
low: Option<u32>,
@@ -158,8 +119,8 @@ enum Phase {
Done,
}
/// Where the probe is aimed: `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]`, else [`POSTER_AT`]. Anything
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
/// Where the probe is aimed: `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]`, else [`POSTER_AT`] (also
/// for `1`).
fn target() -> [f32; 4] {
let Ok(raw) = std::env::var("WOW_PROBE_GOQUEST") else {
return POSTER_AT;
@@ -175,8 +136,8 @@ fn target() -> [f32; 4] {
}
}
/// `.levelup <delta>` on the probe's own character. Nothing is ever selected by this probe, so the
/// command's creature branch (`GetSelectedCreature`) can't fire.
/// `.levelup <delta>` on the probe's own character; nothing is selected, so the command's
/// creature branch (`GetSelectedCreature`) cannot fire.
fn level_to(net: &NetCommands, from: u32, to: u32) {
let delta = i64::from(to) - i64::from(from);
if delta == 0 {
@@ -228,8 +189,7 @@ fn goquest_probe(
return; // our own descriptor hasn't landed
};
let now = time.elapsed_secs_f64();
// Sampled every frame, not just at the windows: a status that flashed on for one frame and was
// pruned would still have been a rendered `!`, and the claim is that none is ever stored.
// Sampled every frame: a status stored for one frame is still a rendered `!`.
if probe.poster.is_some_and(|p| quest.status(p).is_some()) {
probe.poster_ever_stored = true;
}
@@ -318,10 +278,8 @@ 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 (`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.
// A GameObject guid reaches the wire only from a sweep (`0x5eb159`, `0x5eb456`);
// the epoch bump forces one even if the level did not change.
quest.bump_reask();
info!("PROBE_GOQUEST: at level {to}, swept — reading the window");
probe.phase = Phase::Read {
@@ -337,8 +295,7 @@ fn goquest_probe(
Phase::Read { level: at, since } => {
let count = quest.refused_count();
let mine = probe.poster.and_then(|p| quest.refused_for(p));
// The HIGH window must see a *different* answer than LOW, or one stale refusal would
// satisfy both.
// HIGH must see a different answer than LOW, or one stale refusal would satisfy both.
let settled = if at == LOW_LEVEL {
mine
} else {
@@ -420,9 +377,8 @@ fn goquest_probe(
probe.high,
quest.refused_count(),
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
// teardown hang cannot leave a client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
@@ -1,17 +1,10 @@
//! The guard-directions live probe (`WOW_PROBE=guardpoi`) — the end-to-end instrument for
//! [`crate::poi_marker`], inert without the env: once in-world, GM-hop to a Stormwind City Guard,
//! open his gossip on the real wire (`CMSG_GOSSIP_HELLO` → `SMSG_GOSSIP_MESSAGE`), click the
//! directions option, and report what `SMSG_GOSSIP_POI` actually put on the map.
//! The guard-directions live probe (`WOW_PROBE=guardpoi`) for [`crate::poi_marker`]: hops to a
//! Stormwind City Guard, opens his gossip (`CMSG_GOSSIP_HELLO`, `SMSG_GOSSIP_MESSAGE`), clicks the
//! "Weapons Trainer" option and checks the `SMSG_GOSSIP_POI` marker field by field against
//! `points_of_interest` row 808, then logs which minimap draw it gets from here.
//!
//! The verdict is machine-checked against the server's own row, so a wrong parse cannot pass: the
//! guard's "Weapons Trainer" option carries `action_poi_id = 808`, whose `points_of_interest` row
//! is `("Woo Ping", -8796.2, 613.098, icon 6, flags 99)`. The probe asserts the name, the position,
//! the icon and the flags, then classifies the marker exactly as the minimap's landmark pass would
//! — 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 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.
//! Non-combat; grep `PROBE guardpoi:`. The switches are `docs/CONTRIBUTING.md`, "Running it
//! unattended".
use bevy::prelude::*;
@@ -21,21 +14,19 @@ use crate::player::Player;
use crate::poi_marker::PoiMarker;
use crate::ui_gossip::GossipState;
/// A Stormwind City Guard's spawn (vmangos `creature` guid 79664, entry 68, map 0) — the `.go xyz`
/// target. The guard himself is then found in the streamed world by his gossip flag, never by a
/// hardcoded guid.
/// A Stormwind City Guard's spawn (vmangos `creature` guid 79664, entry 68, map 0); the guard is
/// then found in the streamed world by his gossip flag.
const GUARD_AT: [f32; 3] = [-8854.14, 541.299, 105.984];
/// `UNIT_NPC_FLAG_GOSSIP` (bit 0) — every direction-giving guard carries it.
/// `UNIT_NPC_FLAG_GOSSIP` (bit 0).
const NPC_FLAG_GOSSIP: u32 = 0x1;
/// The option we click, by its label. Menu 435 lists it 9th ("Weapons Trainer", `action_poi_id`
/// 808); matching on the text rather than the index keeps the probe honest if the row order moves.
/// The option clicked, matched by label: gossip menu 435's "Weapons Trainer", `action_poi_id` 808.
const OPTION_LABEL: &str = "Weapons Trainer";
/// What `points_of_interest` row 808 says the answer must be.
/// `points_of_interest` row 808.
const EXPECT_NAME: &str = "Woo Ping";
const EXPECT_POS: [f32; 2] = [-8796.2, 613.098];
const EXPECT_ICON: u32 = 6; // ICON_POI_REDFLAG
const EXPECT_FLAGS: u32 = 99; // candidate | draw-the-in-range-icon
/// The in/out-of-range split the minimap's landmark pass applies (`0x811730`, VERIFIED).
const EXPECT_FLAGS: u32 = 99; // bits 0 (a candidate) and 1 (draw the in-range icon)
/// The in/out-of-range split of the minimap's landmark pass (`0x811730`).
const BLIP_EDGE_RATIO: f32 = 0.8;
pub(crate) struct ProbeGuardPoiPlugin;
@@ -52,8 +43,7 @@ struct GuardPoiProbe {
phase: Phase,
}
/// `Wait` → (GM hop sent) `Hopped` → (guard found, hello sent) `Greeted` → (option clicked)
/// `Asked` → verdict → `Done`.
/// `Wait`, `Hopped` (hop sent), `Greeted` (hello sent), `Asked` (option clicked), `Done`.
#[derive(Default, PartialEq)]
enum Phase {
#[default]
@@ -71,7 +61,6 @@ enum Phase {
Done,
}
// One Bevy system's full input set (the taxi-probe shape).
fn guard_poi_probe(
time: ProbeClock,
mut probe: ResMut<GuardPoiProbe>,
@@ -99,7 +88,7 @@ fn guard_poi_probe(
}
Phase::Hopped { sent_at } => {
if now - sent_at < 3.0 {
return; // post-teleport settle: let the guard stream in
return; // let the guard stream in
}
let me = player.pos;
let guard = units.iter().find(|(_, net_e, store, tf)| {
@@ -161,7 +150,7 @@ fn guard_poi_probe(
}
return;
};
// Every field against the server's own row — a mis-ordered parse cannot survive this.
// Every field against the server's row, so a mis-ordered parse fails.
let mut wrong: Vec<String> = Vec::new();
if poi.name != EXPECT_NAME {
wrong.push(format!("name {:?} != {EXPECT_NAME:?}", poi.name));
@@ -181,8 +170,7 @@ fn guard_poi_probe(
wrong.push(format!("flags {} != {EXPECT_FLAGS}", poi.flags));
}
// What the minimap's landmark pass would do with it, from here (the default zoom's
// 133.3-yd view radius — the probe never opens the zoom dial, so this IS the live one).
// The minimap's landmark pass from here, at the default zoom's 133.3-yd view radius.
let w = benilla_assets::coords::bevy_to_wow(player.pos);
let d = ((poi.pos[0] - w[0]).powi(2) + (poi.pos[1] - w[1]).powi(2)).sqrt();
let radius = 133.3;
@@ -191,7 +179,7 @@ fn guard_poi_probe(
format!("POIIcons cell {} at its true spot", poi.icon)
}
true => "nothing (in range, no Flags&2)".to_string(),
false => "the gold guide arrow on the rim, pointing at it (1519)".to_string(),
false => "the gold guide arrow on the rim, pointing at it".to_string(),
};
info!(
"PROBE guardpoi: marker \"{}\" at ({:.1}, {:.1}) icon {} flags {} — {d:.1} yd away \
+13 -29
View File
@@ -1,19 +1,10 @@
//! `WOW_PROBE_LOOK` — the scripted **mouse-turn**, the one player action the probe harness could not
//! synthesize (decision 0621).
//! `WOW_PROBE_LOOK`: the scripted mouse-turn, which drives the per-frame facing stream a key press
//! cannot.
//!
//! `WOW_PROBE_KEY` presses keys, which is enough for the transition opcodes; but since decision 0617
//! the busiest thing on our wire by far is the facing stream a *mouse* turn produces (~one packet per
//! frame). A defect that only shows up under that density — and the runaway hunt looks like exactly
//! that — could not be reproduced headlessly at all: an agent had to ask the director to drive, every
//! iteration. That is the tooling gap this closes.
//!
//! Format: `WOW_PROBE_LOOK="<deg_per_sec>@<start_s>:<duration_s>[;…]"`, e.g. `"90@20:6"` — turn the
//! avatar's aim 90°/s for six seconds starting twenty seconds in. Negative rates turn the other way.
//! It writes [`Player::face_yaw`] directly rather than faking `AccumulatedMouseMotion` + a held right
//! button: the camera's look session gates on the OS cursor being inside the viewport
//! (`cursor_in_viewport`), which is unreliable for an unfocused probe window, and the camera plumbing
//! is not what is under test. From `face_yaw` onward this is the identical path a real mouse-turn
//! takes — the same value `stream_self_movement` diffs to decide on a `MSG_MOVE_SET_FACING`.
//! Format: `WOW_PROBE_LOOK="<deg_per_sec>@<start_s>:<duration_s>[;...]"`, e.g. `"90@20:6"` turns
//! the aim 90 degrees/s for 6 s from 20 s in; a negative rate turns the other way. It writes
//! [`Player::face_yaw`] directly, since the look session gates on the OS cursor being inside the
//! viewport; from there it is the path a mouse-turn takes to `MSG_MOVE_SET_FACING`.
use bevy::prelude::*;
@@ -22,11 +13,8 @@ use benilla_world::schedule::WorldStage;
use super::ProbeClock;
/// One scripted turn: `rate` (rad/s), when it starts, and how long it runs — **wall-clock seconds**
/// ([`ProbeClock`], decision 0789). Both halves want real time here, for one reason: `90°/s for 6 s`
/// has to produce 540° of facing stream whatever the frame rate did, and the stream it feeds is
/// itself paced on the real clock (0615). On the virtual clock the schedule would drift *and* every
/// hitch would silently under-rotate the turn, since its delta is clamped to 250 ms.
/// One scripted turn: `rate` (rad/s) over a window in wall-clock seconds ([`ProbeClock`]); the
/// virtual clock clamps its delta to 250 ms, which would under-rotate across a hitch.
struct Turn {
rate: f32,
at: f32,
@@ -38,8 +26,7 @@ pub(crate) struct ProbeLook {
turns: Vec<Turn>,
}
/// Parse the env script. Absent or unparseable entries yield no turns (with a warning), so the probe
/// is inert unless asked for.
/// Parses `WOW_PROBE_LOOK`; unparseable entries are skipped with a warning.
pub(crate) fn from_env() -> Option<ProbeLook> {
let spec = std::env::var("WOW_PROBE_LOOK").ok()?;
let turns: Vec<Turn> = spec
@@ -69,8 +56,8 @@ pub(crate) fn from_env() -> Option<ProbeLook> {
(!turns.is_empty()).then_some(ProbeLook { turns })
}
/// Rotate the aim for every turn whose window covers this frame. Runs in `WorldStage::Input`
/// **before** the controller, so the frame that sees the new `face_yaw` is the frame that streams it.
/// Rotates the aim for every turn whose window covers this frame, before the controller, so the
/// same frame streams it.
pub(crate) fn drive_probe_look(
probe: Res<ProbeLook>,
time: ProbeClock,
@@ -93,11 +80,8 @@ pub(crate) fn drive_probe_look(
}
}
/// `WOW_PROBE_LOOK`'s registration. Added by the probe fleet when the variable is set, and inert
/// (no resource, no systems) if the script parses to nothing — the same shape every other probe in
/// the fleet has. It orders itself **before** [`crate::player::control`], which is what makes the
/// frame that sees the new `face_yaw` the frame that streams it; the controller knows nothing
/// about it (decision 1174).
/// Registers `WOW_PROBE_LOOK` before [`crate::player::control`]; inert if the script parses to
/// nothing.
pub(crate) struct ProbeLookPlugin;
impl Plugin for ProbeLookPlugin {
+61 -115
View File
@@ -1,48 +1,16 @@
//! The mail-arc live probe (`WOW_PROBE_MAIL=1`) — decision 0544/0548's end-to-end instrument:
//! GM-mail the probe's OWN character, walk to the Goldshire mailbox, open it on the real wire,
//! and drive the inbox/take/send/delete surface through the live Lua VM exactly as a click would,
//! printing a `PROBE_MAIL:` trace line with a PASS/FAIL/SKIP verdict per step and a final
//! `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 mail live probe (`WOW_PROBE_MAIL=1`): GM-mails the probe's own character a letter, money
//! and an item, walks to the Goldshire mailbox, opens it on the real wire and drives the inbox
//! through the live Lua VM as clicks would: read, copy and read the letter, take the money and the
//! item, two refused sends, delete. One `PROBE_MAIL:` line per step with PASS/FAIL/SKIP, then
//! `PROBE_MAIL: DONE pass=<n> fail=<m>`, and it exits. `WOW_CHAR` doubles as the send target. The
//! switches are `docs/CONTRIBUTING.md`, "Running it unattended".
//!
//! ## 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
//! `SEC_MODERATOR` (1); `.send items <name> "subject" "text" item[:count]…` and
//! `.send money <name> "subject" "text" <copper>` are both `SEC_ADMINISTRATOR` (6)
//! (`AccountTypes`, `shared/Common.h` l.138-145: `SEC_MODERATOR=1`, `SEC_GAMEMASTER=3`,
//! `SEC_ADMINISTRATOR=6`). **Probe accounts are gmlevel 6**, so all three land. They did *not*
//! until 2026-07-26 — 0645 recorded the raise but never applied it, and the accounts sat at 3
//! (0651). While they did, steps (c)/(d) below degraded to SKIP against a permission floor that
//! was real, and that SKIP hid a defect in the probe itself: it re-found the money/item row by
//! predicate each tick, so a *successful* take — which zeroes the money and clears the attachment
//! — made the row stop matching and read as "no row ever appeared". Both steps now remember the
//! id they took from, and a missing row is a FAIL, because at gmlevel 6 there is no longer an
//! innocent reason for one. All three GM
//! sends build their `MailDraft` and call `MailDraft::SendMailTo` DIRECTLY
//! (`HandleSendMailCommand`/`HandleSendItemsCommand`/`HandleSendMoneyCommand`, `MiscCommands.cpp`
//! ~l.1012-1145) — never through `WorldSession::HandleSendMail` (`MailHandler.cpp`), whose
//! `MAIL_ERR_CANNOT_SEND_TO_SELF` gate (l.204) lives only in the CMSG-driven path. So a GM
//! `.send mail <self>` bypasses the self-check entirely and lands in the sender's own mailbox —
//! confirmed by reading `MailDraft::SendMailTo` (`Mail/Mail.cpp` l.300+) end to end: no self
//! comparison anywhere in it. The GM path's `deliver_delay` also defaults to `0`
//! (`Mail.h` l.254; the command handlers pass none) — unlike the player CMSG path's
//! `MailDeliveryDelay` (decision 0544's 1 h note), so a successful GM send (permission allowing)
//! is in the inbox on the very next `CMSG_GET_MAIL_LIST`, no extra wait needed. Sender identity:
//! `MailSender(MAIL_NORMAL, <the GM's own char guid counter>, MAIL_STATIONERY_GM)` — a real player
//! sender guid, GM stationery (61, `ui_mail.rs`'s `STATIONERY_GM`).
//!
//! ## The run recipe
//!
//! ```text
//! WOW_DATA=<vanilla Data dir> WOW_USER=probe2 WOW_PASS=pprobe2 WOW_CHAR=Probetwo \
//! WOW_PROBE_MAIL=1 cargo run -q -p benilla
//! ```
//! (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.
//! `.send mail <name> "subject" "text"` needs `SEC_MODERATOR`; `.send items … item[:count]` and
//! `.send money … <copper>` need `SEC_ADMINISTRATOR` (vmangos `Chat.cpp:935-938`). Each builds a
//! `MailDraft` and calls `SendMailTo` directly (`MiscCommands.cpp:1012-1166`), never through
//! `HandleSendMail`, whose `MAIL_ERR_CANNOT_SEND_TO_SELF` check (`MailHandler.cpp:204`) is on the
//! CMSG path only. So a GM send to oneself lands, with no delivery delay (`Mail.h:254`), from a
//! `MAIL_NORMAL` sender carrying the GM's own guid, on GM stationery (61).
use bevy::prelude::*;
@@ -55,33 +23,29 @@ use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectSt
use crate::player::Player;
use crate::ui_mail::MailOpen;
/// The Goldshire mailbox (vmangos `gameobject` guid 2978, entry 142075, map 0) — live-DB verified
/// position.
/// The Goldshire mailbox (vmangos `gameobject` guid 2978, entry 142075, map 0).
const MAILBOX_AT: [f32; 3] = [-9455.99, 45.82, 56.44];
/// `GAMEOBJECT_TYPE_MAILBOX` (decision 0544/0548) — the GO strategy type this probe scans for.
/// `GAMEOBJECT_TYPE_MAILBOX`, the type this probe scans for.
const GO_TYPE_MAILBOX: i32 = 19;
/// The mailbox's server-side interaction check is 5 yd (`CheckMailBox`, decision 0544); scan
/// generously wide so a slightly-off `.go` landing still finds it.
/// Wider than the server's 5 yd `CheckMailBox`, so a slightly-off `.go` landing still finds it.
const MAILBOX_SCAN_RANGE: f32 = 10.0;
/// The probe's GM-mailed item: Linen Cloth ×5 (the task's fixture entry).
/// The GM-mailed item: Linen Cloth x5.
const ITEM_ENTRY: u32 = 2589;
const ITEM_COUNT: u32 = 5;
/// The probe's GM-mailed money, in copper (~1234c, the task's fixture amount).
/// The GM-mailed money, in copper.
const MONEY_COPPER: u32 = 1234;
/// `checked` mask bit READ (`0x1`, vmangos `Mail.h`, decision 0544) — redeclared locally (the
/// `ui_mail` copy is private to that module) purely for this trace's printed flags.
/// `checked` bit READ (`0x1`, vmangos `Mail.h`), for the trace's printed flags.
const CHECKED_READ: u32 = 0x1;
/// `checked` mask bit COPIED (`0x4`) — the wire's `textCreated`: step (b2) asserts the
/// `CMSG_MAIL_CREATE_TEXT_ITEM` round-trip stamps it.
/// `checked` bit COPIED (`0x4`), the wire's `textCreated`: step (b2) asserts the
/// `CMSG_MAIL_CREATE_TEXT_ITEM` round trip sets it.
const CHECKED_COPIED: u32 = 0x4;
const SETTLE_SECS: f64 = 3.0;
const SCAN_TIMEOUT_SECS: f64 = 15.0;
const LIST_TIMEOUT_SECS: f64 = 15.0;
const BODY_TIMEOUT_SECS: f64 = 10.0;
/// How long a GM-sent row is given to show up before the dependent step FAILs — generous, but
/// bounded (never hangs).
/// How long a GM-sent row is given to show up before the dependent step FAILs.
const ROW_GRACE_SECS: f64 = 5.0;
const ACTION_TIMEOUT_SECS: f64 = 8.0;
@@ -94,29 +58,24 @@ impl Plugin for ProbeMailPlugin {
}
}
/// The probe's phase machine + the identities it discovers along the way (kept resource-level,
/// not per-variant, since several later phases re-resolve the same row by its stable
/// `message_id` — list order can shift after a take/delete re-sync).
/// The probe's phase machine plus the identities it finds; rows are kept by `message_id`, since
/// list order shifts after a take or delete.
#[derive(Resource, Default)]
struct MailProbe {
phase: Phase,
/// `WOW_CHAR` — the probe's own name, its GM-mail target and its self-send target. Read once
/// at [`Phase::Wait`] (the "read it from the same place login does" instruction — `net/io.rs`
/// reads the identical env var).
/// `WOW_CHAR`: the probe's own name, its GM-mail and self-send target, read once at
/// [`Phase::Wait`].
char_name: String,
/// The plain letter's `message_id`, once found (stable across resyncs; row order isn't).
/// The plain letter's `message_id`, once found.
letter_id: Option<u32>,
passes: u32,
fails: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
/// Latched once [`Phase::Done`] has fired its exit.
exited: bool,
}
/// Every field is `Copy` (f64/bool/i64) — the phase is snapshotted out of the resource each tick
/// ([`mail_probe`]'s `let phase = probe.phase;`) so the match arms are free to mutate `probe`
/// (counters, `letter_id`, `char_name`) without fighting the borrow checker over `probe.phase`
/// itself; an arm that wants to "keep waiting" simply never writes `probe.phase`, leaving the
/// pre-match value in place.
/// Copied out of the resource each tick so the arms can mutate `probe`; an arm that keeps
/// waiting leaves `probe.phase` alone.
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
@@ -125,27 +84,25 @@ enum Phase {
Settling {
sent_at: f64,
},
/// Mailbox clicked ([`MailOpen::click`], decision 0544/0548 — no packet); waiting for the
/// first `SMSG_MAIL_LIST_RESULT` (step a).
/// Mailbox clicked ([`MailOpen::click`], a local open with no packet); waiting for the first
/// `SMSG_MAIL_LIST_RESULT` (step a).
WaitList {
clicked_at: f64,
},
/// The list landed — `GetInboxText` the plain letter (mark-read + ask-once body, step b).
/// The list landed: `GetInboxText` the plain letter (mark-read and ask-once body, step b).
OpenLetter,
/// Waiting for the letter's `CMSG_ITEM_TEXT_QUERY` reply to land in the body cache.
WaitBody {
since: f64,
},
/// `TakeInboxTextItem` on the opened letter — the letter button's permanent-copy verb
/// `TakeInboxTextItem` on the opened letter, the permanent-copy verb
/// (`CMSG_MAIL_CREATE_TEXT_ITEM`, step b2): PASS when the re-synced row carries COPIED.
CopyLetter {
since: f64,
sent: bool,
},
/// Read the permanent copy (step b3): find the Plain Letter (8383) in the bags via the
/// container Lua, right-click it through the real `UseContainerItem` route, PASS when the
/// reader window paints the GM letter's body — then destroy the copy (else bags accumulate
/// one per run).
/// Read the permanent copy (step b3): find the Plain Letter (item 8383) in the bags, click it
/// through `UseContainerItem`, PASS when the reader shows the body, then destroy the copy.
ReadLetter {
since: f64,
/// The letter's `(lua bag, 1-based slot)` once found and clicked.
@@ -154,26 +111,22 @@ enum Phase {
/// `TakeInboxMoney` on the money row (step c).
TakeMoney {
since: f64,
/// The message id we sent the take for, once sent. It is remembered rather than re-found
/// each tick because **taking is what makes the row stop matching**: a successful
/// `TakeInboxMoney` zeroes `money` (or drops the row), so a predicate re-search returns
/// `None` on exactly the ticks that prove success. That bug reported every successful take
/// as "no money row appeared" and hid behind the old gmlevel-3 floor, where the row really
/// never arrived (0651).
/// The message id the take was sent for, kept because a successful take zeroes `money`
/// or drops the row, so re-searching would read success as absence.
taken: Option<u32>,
},
/// `TakeInboxItem` on the item row (step d). Same remembered-id reason as [`Self::TakeMoney`].
/// `TakeInboxItem` on the item row (step d); the taken id is kept as in [`Self::TakeMoney`].
TakeItem {
since: f64,
taken: Option<u32>,
},
/// `SendMail` to the probe's own name — expects `CANNOT_SEND_TO_SELF` (step e).
/// `SendMail` to the probe's own name, expecting `CANNOT_SEND_TO_SELF` (step e).
SendSelf {
since: f64,
sent: bool,
baseline: i64,
},
/// `SendMail` to a name that doesn't exist — expects `RECIPIENT_NOT_FOUND` (step f).
/// `SendMail` to a name that does not exist, expecting `RECIPIENT_NOT_FOUND` (step f).
SendBad {
since: f64,
sent: bool,
@@ -187,14 +140,12 @@ enum Phase {
Done,
}
/// Find the first row matching `pred`, by stable `message_id` (never a position — resyncs shift
/// rows around).
/// The first row matching `pred`, by its stable `message_id`.
fn find_by(mail: &MailOpen, pred: impl Fn(&MailListEntry) -> bool) -> Option<u32> {
mail.mails.iter().find(|e| pred(e)).map(|e| e.message_id)
}
/// The 1-based display index a `message_id` currently sits at, or `None` if it's gone (taken/
/// deleted/expired-and-purged).
/// The 1-based display index a `message_id` currently sits at, `None` once it is gone.
fn index_of(mail: &MailOpen, mail_id: u32) -> Option<u32> {
mail.mails
.iter()
@@ -206,8 +157,7 @@ fn entry_of(mail: &MailOpen, mail_id: u32) -> Option<&MailListEntry> {
mail.mails.iter().find(|e| e.message_id == mail_id)
}
/// Read the Lua-side `ProbeMailEvents` log length (the `UI_ERROR_MESSAGE` hook) — `0` on any eval
/// hiccup (treated as "nothing observed yet", never a panic).
/// The Lua-side `ProbeMailEvents` log length (the `UI_ERROR_MESSAGE` hook); 0 on an eval error.
fn events_len(script: &UiScript) -> i64 {
script
.eval::<i64>("return table.getn(ProbeMailEvents or {})")
@@ -236,11 +186,10 @@ fn mail_probe(
return; // not in-world yet
}
let Some(script) = script else {
return; // no UI VM this build (headless net-only) — nothing this probe can drive
return; // no UI VM in this build, so nothing to drive
};
let now = time.elapsed_secs_f64();
// A cheap `Copy` snapshot (see [`Phase`]'s doc) — frees every arm below to mutate `probe`
// freely and to leave `probe.phase` untouched when it just wants to keep waiting.
// A `Copy` snapshot, so the arms can mutate `probe` (see `Phase`).
let phase = probe.phase;
match phase {
@@ -252,10 +201,8 @@ fn mail_probe(
probe.phase = Phase::Done;
return;
}
// The UI_ERROR_MESSAGE hook (steps e/f's observation channel) — a hidden frame that
// logs `arg1` into a Lua table this probe polls, the same "reach out of the VM"
// pattern as `ProbeLuaPlugin`'s `ProbeLog`, but as a readable log instead of a
// one-shot info! line (we need to distinguish two different error texts in order).
// The `UI_ERROR_MESSAGE` hook for steps e and f: a hidden frame logs `arg1` into a Lua
// table the probe polls, so the two error texts are told apart in order.
if let Err(e) = script.run(
r#"
if not ProbeMailHooked then
@@ -273,8 +220,7 @@ fn mail_probe(
}
let name = &probe.char_name;
info!("PROBE_MAIL: GM-mailing {name} (plain letter, money, item) then heading to the Goldshire mailbox");
// Three GM sends (verified syntax above) + the teleport, one chat burst (the
// `ProbeChatPlugin`/`probe_taxi` idiom: GM dot-commands ride as plain Say lines).
// The three GM sends and the teleport, as plain Say lines.
let [x, y, z] = MAILBOX_AT;
for text in [
format!(".send mail {name} \"probe letter\" \"hello\""),
@@ -301,7 +247,10 @@ fn mail_probe(
&& tf.translation.distance(me) < MAILBOX_SCAN_RANGE
});
if let Some((guid, ..)) = mailbox {
info!("PROBE_MAIL: mailbox {:#x} in range — clicking (local open, no packet, decision 0544/0548)", guid.0);
info!(
"PROBE_MAIL: mailbox {:#x} in range — clicking (local open, no packet)",
guid.0
);
mail.click(guid.0);
probe.phase = Phase::WaitList { clicked_at: now };
} else if now - sent_at > SCAN_TIMEOUT_SECS {
@@ -356,10 +305,10 @@ fn mail_probe(
};
probe.letter_id = Some(letter_id);
let Some(idx) = index_of(&mail, letter_id) else {
return; // shouldn't happen the same frame we just found it — re-poll
return; // found this frame, so not expected; re-poll
};
// 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.
// `CheckInbox()` twice: `0x4aeab0` throttles it to once per 60 s, so the re-call is a
// no-op.
if let Err(e) = script.run(&format!("CheckInbox() CheckInbox() GetInboxText({idx})")) {
error!("PROBE_MAIL: FAIL (b) — GetInboxText({idx}) errored: {e}");
probe.fails += 1;
@@ -468,8 +417,8 @@ fn mail_probe(
Phase::ReadLetter { since, slot } => {
match slot {
None => {
// Find the Plain Letter (8383) in the bags via the container Lua the bag UI
// itself uses; encoded bag*100+slot (-1 = not there yet).
// Find the Plain Letter (item 8383) via the bag UI's own container Lua;
// encoded bag*100+slot (-1 = not there yet).
let found = script
.eval::<i64>(
"for bag=0,4 do local n=GetContainerNumSlots(bag) or 0 \
@@ -535,10 +484,8 @@ fn mail_probe(
}
}
Phase::TakeMoney { since, taken } => {
// The id is REMEMBERED across ticks, never re-found: a successful take is exactly what
// makes the row stop matching `money > 0`, so re-searching would read success as
// absence (0651). Matched as a pair rather than with guards — guarded `Some(_) if …`
// arms can't be proven exhaustive by rustc, forcing a dead catch-all.
// The id is kept across ticks: a successful take makes the row stop matching
// `money > 0`. Matched as a pair, since guarded arms would need a dead catch-all.
let next = Phase::TakeItem {
since: now,
taken: None,
@@ -778,9 +725,8 @@ fn mail_probe(
"PROBE_MAIL: DONE pass={} fail={}",
probe.passes, probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
// teardown hang cannot leave a client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
+8 -12
View File
@@ -1,13 +1,10 @@
//! The melee live probe (`WOW_PROBE=melee`) — an agent-side instrument, inert without the env:
//! 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 checkout's probe identity (the `probe` skill) + an outer
//! `timeout`.
//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it fires the
//! attack-nearest core ([`AttackNearestRequest`]) every few seconds, so the character fights
//! whatever is closest while `WOW_MOVE_TRACE=<path>` records the swing, impact and combat-text
//! timeline.
//!
//! **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.
//! Never run it unattended (`docs/METHOD.md`, "The local server"): it fights with no health
//! awareness and the character dies.
use bevy::prelude::*;
@@ -23,9 +20,8 @@ impl Plugin for ProbeMeleePlugin {
}
}
/// Every few seconds: with a live selection, (re)send the attack swing at it (idempotent while
/// already auto-attacking; covers the auto-acquired-attacker case the acquire core skips); with
/// none, run the acquire core. Re-acquires the next victim after a kill clears the selection.
/// Every 3 s: swing at the selection (idempotent while auto-attacking, and it covers an attacker
/// the acquire core skips), or with none, run the acquire core.
fn melee_probe(
time: ProbeClock,
mut last: Local<f64>,
@@ -1,44 +1,21 @@
//! `WOW_PROBE_MODEL_CAMERA=1` — the live probe for the `<Model>` widget's **perspective leg**
//! (decision 2027): a plain `CreateFrame("Model")` pointed at a camera-bearing file, framed by the
//! file's own camera through a camera of its own into its cell of the tile atlas.
//! `WOW_PROBE_MODEL_CAMERA=1`: the live probe for the `<Model>` widget's perspective leg. It builds
//! a plain `CreateFrame("Model")` on a camera-bearing file and reads the camera and root the
//! renderer actually built, which the unit tests in `crate::ui_models` cannot reach.
//!
//! ## What it asks, and why it is a probe rather than a test
//! Each leg projects three model-space points through that camera and root and compares the NDC
//! with the baseline's:
//!
//! 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.
//! - `scale`: `SetModelScale(3)` moves nothing, as the authored eye rides the model's root.
//! - `position`: `SetPosition(0.4, -0.3, 0.9)` moves nothing, for the same reason.
//! - `facing`: `SetFacing(1.0)` moves nothing: the up vector `0x7ac640` builds is model-space `+Z`
//! at `roll = 0`, the facing's own axis. Only `<PlayerModel>`'s frozen camera (`0x7acf10`)
//! shows a facing.
//! - `ortho`, the control: `SetCamera(9)` is past the file's camera count, so the widget installs
//! the NULL camera and falls to the orthographic leg, and the perspective camera goes inactive.
//!
//! 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:
//!
//! - `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**: 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
//! drawing would fail here.
//!
//! Three screenshots ride along — the baseline, the scaled/offset one and the turned one — as the
//! existence evidence and for the director's own eye. They are the whole window, so they are not
//! compared: the world behind the pane moves.
//!
//! ## The run recipe
//! ```text
//! WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive WOW_UNATTENDED=1 WOW_NOSOUND=1 \
//! WOW_PROBE_MODEL_CAMERA=1 cargo run -q -p benilla
//! ```
//! (the 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.
//! Three whole-window screenshots go to the local-state folder as existence evidence; they are not
//! compared. `WOW_TILE_TRACE=1` prints the leg, record, eye and matrix per pane per frame. The
//! switches are `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::camera::visibility::RenderLayers;
use bevy::prelude::*;
@@ -50,24 +27,20 @@ use super::probes::ProbeClock;
use crate::net::SelfPlayer;
use crate::ui_models::{TilePerspectiveCamera, TileRoot, UiModelTiles};
/// The file the probe frames. `Creature\Wolf\Wolf.m2` carries **two** cameras
/// (`benilla-extract m2cam`: index 0 type 0 at eye `(1.6927, 0.7238, 0.8837)` fov `0.95002`,
/// index 1 type 1 at eye `(5.5556, 0, 1.8056)` fov `0.67620`), so it exercises the raw-index
/// selection AND has an index past the count for the orthographic control.
/// The file framed. `Creature\Wolf\Wolf.m2` carries two cameras (`benilla-extract m2cam`: index
/// 0 type 0, eye `(1.6927, 0.7238, 0.8837)`, fov `0.95002`; index 1 type 1, eye
/// `(5.5556, 0, 1.8056)`, fov `0.67620`), so index 9 is past the count.
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 a worked
/// example of the projection `0x5c3cc0` (`θ = 0.287938 · fov`).
/// The pane's size in FrameXML units: the pet pane's `318x224`, a worked example of the
/// projection `0x5c3cc0` (`theta = 0.287938 * fov`).
const PANE_W: f32 = 318.0;
const PANE_H: f32 = 224.0;
/// Frames to let a change settle before the numbers are read: the extract republishes the request
/// on the next conversion and the renderer aims the camera the frame after that.
/// Frames to let a change settle: the extract republishes it, then the renderer aims the camera.
const SETTLE_FRAMES: u32 = 8;
/// NDC agreement the cancellation legs must hold to. Generous against f32 round-off through two
/// 4×4 composes; a leg that failed to cancel misses by tens of percent, not by a thousandth.
/// NDC agreement the cancelling legs must hold; a failed cancel misses by tens of percent.
const NDC_EPS: f32 = 1.0e-3;
/// The Lua that builds the pane. Anonymous is the corpus shape (pfUI's autocast shine), but the
/// probe needs a handle on it, so it is global.
/// The Lua that builds the pane, named so the legs can reach it.
const BUILD: &str = r#"
ProbeModelCam = CreateFrame("Model", "ProbeModelCam", UIParent)
ProbeModelCam:SetWidth(318)
@@ -88,7 +61,7 @@ impl Plugin for ProbeModelCameraPlugin {
}
}
/// One leg's reading: the NDC of the three probe points, and the label to report it under.
/// One leg's reading: the NDC of the three probe points.
#[derive(Clone, Copy, Default)]
struct Reading([Vec2; 3]);
@@ -110,7 +83,7 @@ enum Phase {
Await {
since: f64,
},
/// A change is in flight; read it in `at`.
/// A change is in flight; read after [`SETTLE_FRAMES`].
Settle {
frames: u32,
leg: Leg,
@@ -128,7 +101,7 @@ enum Leg {
}
impl Leg {
/// The Lua that puts the pane INTO this leg — run when the leg is entered, before its reading.
/// The Lua that puts the pane into this leg, run before its reading.
fn setup(self) -> &'static str {
match self {
Leg::Baseline => "",
@@ -137,9 +110,7 @@ impl Leg {
"ProbeModelCam:SetModelScale(1) ProbeModelCam:SetPosition(0.4, -0.3, 0.9)"
}
Leg::Facing => "ProbeModelCam:SetPosition(0, 0, 0) ProbeModelCam:SetFacing(1.0)",
// The control: index 9 is past Wolf's two cameras, so the widget installs the NULL
// camera and the pane drops to the orthographic leg. Its perspective camera goes
// inactive, which is what the reading detects.
// Past Wolf's two cameras: the NULL camera, the orthographic leg.
Leg::Ortho => "ProbeModelCam:SetCamera(9)",
}
}
@@ -155,8 +126,7 @@ impl Leg {
}
}
/// The three model-space points the legs are compared on — a wolf-sized spread, in Bevy space
/// (the frame the tile root and the camera both work in).
/// The three model-space points the legs compare, a wolf-sized spread in Bevy space.
fn probe_points() -> [Vec3; 3] {
[
benilla_assets::coords::wow_to_bevy([0.0, 0.0, 0.8]),
@@ -165,9 +135,8 @@ fn probe_points() -> [Vec3; 3] {
]
}
/// Project the three points through whatever the renderer built this frame — the ACTIVE
/// perspective camera and the tile root under its layer. `None` when no perspective camera is
/// aimed (which is the orthographic leg, and the control's expected answer).
/// Projects the points through the active perspective camera and the tile root on its layer;
/// `None` with no perspective camera aimed, the orthographic leg.
fn read_leg(
cams: &Query<
(&Camera, &GlobalTransform, &Projection, &RenderLayers),
@@ -176,9 +145,7 @@ fn read_leg(
roots: &Query<(&GlobalTransform, &RenderLayers), With<TileRoot>>,
) -> Option<Reading> {
let (_, cam_tf, proj, layers) = cams.iter().find(|(c, _, _, _)| c.is_active)?;
// The pane's root is the one on the camera's OWN layer — a perspective camera sees exactly one
// tile by construction, and the interface has other tiles up (the minimap ping, the map arrow)
// whose roots would otherwise be picked at random.
// The root on the camera's own layer: other tiles (the minimap ping, the map arrow) are up.
let (root, _) = roots.iter().find(|(_, l)| *l == layers)?;
let clip = proj.get_clip_from_view() * cam_tf.to_matrix().inverse() * root.to_matrix();
let mut out = [Vec2::ZERO; 3];
@@ -190,8 +157,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 (docs/METHOD.md's one-folder rule).
// Written under `local_state`, never into the install.
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;
@@ -243,8 +209,7 @@ fn model_camera_probe(
leg: Leg::Baseline,
};
} else if now - since > 30.0 {
// The asset never landed, or the pane never reached the paint list. An
// environmental miss, not a wrong answer.
// The asset never landed or the pane never painted: a SKIP, not a wrong answer.
error!(
"PROBE_MODEL_CAMERA: SKIP — no tile after 30 s (cells={}, camera aimed={})",
tiles.cells.len(),
@@ -342,9 +307,7 @@ fn model_camera_probe(
probe.passes, probe.fails, probe.skips
);
exit.write(AppExit::Success);
// The polite `AppExit` plus a hard backstop, the harness's standard pair
// (`ProbeExitPlugin::fire_probe_exit`): a net or winit teardown hang must not leave a
// zombie client holding the probe account.
// A hard backstop, so a teardown hang cannot keep the account held.
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_MODEL_CAMERA: still alive 5s after AppExit — hard exit");
@@ -1,12 +1,7 @@
//! The partner live probe (`WOW_PROBE=partner`) — the party arc's instrument (decision 0434):
//! a second client that says YES. Once in-world it auto-accepts every group invite, so the
//! director can exercise the whole party surface — invite, roster lines, member frames,
//! 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, `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 checkout's probe identity (the `probe` skill).
//! The partner live probe (`WOW_PROBE=partner`): a second client that accepts every group
//! invite and duel challenge, so the party and duel surfaces can be exercised with it as the
//! other player. It never swings, so an unstruck duel times out; the switches are
//! `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::prelude::*;
@@ -21,10 +16,8 @@ impl Plugin for ProbePartnerPlugin {
}
}
/// A pending invite is answered with `CMSG_GROUP_ACCEPT` the frame it lands. Clearing
/// `pending_invite` here keeps the probe's own PARTY_INVITE popup from ever arming (and if the
/// popup won a same-frame race, its hide-path `DeclineGroup` is a server no-op once we're
/// grouped — vmangos ignores a decline with no pending invite).
/// Accept a pending invite the frame it lands; taking `pending_invite` keeps the invite popup
/// from arming, and a racing `DeclineGroup` is a no-op once grouped (`GroupHandler.cpp:200`).
fn partner_probe(
self_player: Query<(), With<SelfPlayer>>,
mut group: ResMut<GroupState>,
@@ -38,9 +31,7 @@ fn partner_probe(
info!("partner probe: accepting {inviter}'s group invite");
let _ = net.0.send(crate::net::ClientCommand::GroupAccept);
}
// A pending challenge is answered with `CMSG_DUEL_ACCEPTED` the frame it lands. Taking the
// challenger discharges the popup debt the same way the UI feed's DUEL_REQUESTED edge would,
// so no dialog is left owed on the probe.
// Taking the challenger discharges the DUEL_REQUESTED popup, as the UI feed would.
let arbiter = duel.arbiter;
if let Some(challenger) = duel.take_challenger() {
info!("partner probe: accepting duel from {challenger:#018x} (arbiter {arbiter:#018x})");
+18 -42
View File
@@ -1,28 +1,12 @@
//! `WOW_PROBE_PITCH` — the scripted **dive**: aim a swimming avatar's nose up or down without a
//! human on the mouse. The twin of [`super::probe_look`]'s scripted turn, and it closes the same
//! shape of gap.
//! `WOW_PROBE_PITCH`: the scripted dive, aiming a swimming avatar's nose up or down. In play the
//! swim pitch (the wire's pitch tail, the body-pitch render `0x60a110`) is only written by
//! mouse-look, which needs a moving OS cursor in the viewport.
//!
//! The swim body pitch has exactly one writer in gameplay — the mouse-look push in
//! `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 (`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, 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,
//! under the same ±89° clamp — rather than faking mouse motion, for [`super::probe_look`]'s reason
//! exactly: the camera plumbing is not what is under test, and its `cursor_in_viewport` gate is
//! unreliable for a window nobody is looking at.
//! Format: `WOW_PROBE_PITCH="<deg>@<start_s>[:<deg_per_sec>][;...]"`, nose-up positive like
//! `Player::mover_pitch` and the wire. `"-30@22;30@40"` aims 30 degrees down at 22 s and up at 40;
//! `"-45@20:9"` sweeps at 9 degrees/s from 20 s. The latest started entry wins and is re-applied
//! every frame. It writes [`Player::aim_pitch`], the field the reference's `SetPitch`
//! (`0x7c6f70`) writes, under the same 89-degree clamp.
use bevy::prelude::*;
@@ -31,10 +15,8 @@ use benilla_world::schedule::WorldStage;
use super::ProbeClock;
/// One scripted aim: the target pitch (radians, +up), when it takes effect, and an optional sweep
/// rate (rad/s) applied from that moment — **wall-clock seconds** ([`ProbeClock`], decision 0789),
/// for [`super::probe_look`]'s reason: a dive scripted to reach 30° by second 25 has to reach it at
/// second 25 whatever the frame rate did, and the wire tail it feeds is paced on the real clock.
/// One scripted aim: pitch (radians, +up), start and sweep rate (rad/s), in wall-clock seconds
/// ([`ProbeClock`]).
struct Aim {
pitch: f32,
at: f32,
@@ -46,8 +28,7 @@ pub(crate) struct ProbePitch {
aims: Vec<Aim>,
}
/// Parse the env script. Absent or unparseable entries yield no aims (with a warning), so the probe
/// is inert unless asked for.
/// Parses `WOW_PROBE_PITCH`; unparseable entries are skipped with a warning.
pub(crate) fn from_env() -> Option<ProbePitch> {
let spec = std::env::var("WOW_PROBE_PITCH").ok()?;
let aims: Vec<Aim> = spec
@@ -76,8 +57,8 @@ pub(crate) fn from_env() -> Option<ProbePitch> {
(!aims.is_empty()).then_some(ProbePitch { aims })
}
/// Hold the aim of the latest entry whose start has passed. Runs in `WorldStage::Input` **before**
/// the controller, so the swim frame that reads the pitch is the frame that renders and streams it.
/// Holds the latest started entry's aim, before the controller, so the same frame renders and
/// streams it.
pub(crate) fn drive_probe_pitch(
probe: Res<ProbePitch>,
time: ProbeClock,
@@ -89,8 +70,7 @@ pub(crate) fn drive_probe_pitch(
return; // in-world only, like the other probes
}
let now = time.elapsed_secs();
// The LAST armed entry wins, so a script reads as a timeline rather than a sum — a dive
// followed by a climb is two entries, not an arithmetic accident.
// The latest armed entry wins: a script is a timeline, not a sum.
let Some(aim) = probe
.aims
.iter()
@@ -99,21 +79,17 @@ pub(crate) fn drive_probe_pitch(
else {
return;
};
// The STORED value, not the requested one: `aim_pitch` clamps at ±89°, and a sweep that runs
// past it must not log a number the body never held.
// Log the stored value: `aim_pitch` clamps at 89 degrees either way.
let held = player.aim_pitch(aim.pitch + aim.sweep * (now - aim.at));
// One line per whole degree crossed: enough to read the timeline back out of the log, few
// enough not to drown it. The observer's own reading is the point of comparison.
// One line per whole degree crossed.
let whole = held.to_degrees().round();
if announced.replace(whole) != Some(whole) {
info!("probe-pitch: aiming {whole:+.0}° at t={now:.1}s");
}
}
/// `WOW_PROBE_PITCH`'s registration — the same shape (and the same ordering rationale) as
/// [`super::probe_look`]'s: inert with no resource and no systems when the script parses to
/// nothing, and ordered before [`crate::player::PlayerControlSet`] so the controller knows nothing
/// about it (decision 1174).
/// Registers `WOW_PROBE_PITCH` before [`crate::player::PlayerControlSet`]; inert if the script
/// parses to nothing.
pub(crate) struct ProbePitchPlugin;
impl Plugin for ProbePitchPlugin {
+76 -169
View File
@@ -1,18 +1,6 @@
//! 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
//!
//! Every probe account owns one level-1 character, and every session that needs anything else has
//! been hand-assembling it out of GM commands. Mined from 507 MB of this project's own session
//! transcripts, the standing tax is: `.go xyz` 750×, `.additem` 434×, `.modify` 345×, `.gm off`
//! 257×, `.revive` **121×**, `.learn <one spell id>` a few dozen times — while
//! `.character premade` (96 ready-made BiS/twink **gear** templates plus 53 **talent** ones sitting
//! in this deploy's world DB, every class at 19/29/39/49/60) has been used **zero** times, because
//! nobody knew it was there. That is the shape of the problem: the primitives exist and are undiscoverable, so
//! each session re-derives a worse version of them.
//!
//! The rig is the one verb that hides all of it: name a body, get a body.
//! The probe rig (`WOW_RIG="<spec>"`): puts this checkout's probe account into a chosen body
//! through GM commands and hands over a world ready to test. Non-combat: it creates, configures,
//! places and stops.
//!
//! ```text
//! WOW_RIG="tauren druid 60 gear:heal-preraid-bis spec:heal-preraid-bis at:ThunderBluff"
@@ -21,58 +9,26 @@
//! WOW_RIG="nightelf druid" # just a body of that shape, level 1
//! ```
//!
//! ## What it does
//! Race and class name a character, since vmangos has no class-change command: the name is
//! `<Race3><Class3><word>[f]`, the word this checkout's (`run_mode::rig_suffix`), so a later run
//! reuses the same body. Without race and class the rig configures whatever `WOW_CHAR` logs in as.
//! When a create fails with `CHAR_CREATE_SERVER_LIMIT`, the rig deletes this checkout's rig-named
//! character cheapest to rebuild and retries once; no other character is ever deleted.
//!
//! **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><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 GM commands behind it, with the level each needs (vmangos `Chat.cpp`):
//! - a dead body: `.revive` (SEC_GAMEMASTER 3), always.
//! - `<level>`: `.character level N` (SEC_DEVELOPER 5), then `.learn all_myclass` (5), which
//! teaches all class spells and talents.
//! - `gear:<t>`: `.character premade gear <t>` (SEC_BASIC_ADMIN 4), which levels up and equips.
//! - `spec:<t>`: `.character premade spec <t>` (4), which resets talents and learns the tree.
//! - `at:<name>`: `.tele <name>` (SEC_TICKETMASTER 2), a `game_tele` row.
//! - `at:m,x,y,z[,o]`: `.go xyz x y z m`, or `.go xyzo` to pin the facing (2).
//! - `gm:on|off`: `.gm on|off` (SEC_TICKETMASTER 2).
//!
//! **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 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.
//!
//! **Then it applies state, in an order that matters:** revive before anything (a ghost's commands
//! half-apply), level before gear (`ApplyPremadeGearTemplateToPlayer` only levels *up*, never
//! down), spells after level, teleport last (so a fixup lands where you asked, not where you were).
//! 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 the vmangos source, github.com/vmangos/core)
//!
//! | rig token | command | needs | note |
//! |---|---|---|---|
//! | (always, if dead) | `.revive` | SEC_GAMEMASTER 3 | |
//! | `<level>` | `.character level N` | SEC_DEVELOPER 5 | no name arg ⇒ self |
//! | (with a level) | `.learn all_myclass` | SEC_DEVELOPER 5 | = all class spells **+** all talents |
//! | `gear:<t>` | `.character premade gear <t>` | SEC_BASIC_ADMIN 4 | name or entry; levels up + equips |
//! | `spec:<t>` | `.character premade spec <t>` | SEC_BASIC_ADMIN 4 | resets talents, learns the tree |
//! | `at:<name>` | `.tele <name>` | SEC_TICKETMASTER 2 | 997 named rows in `game_tele` |
//! | `at:m,x,y,z[,o]` | `.go xyz x y z m` (`.go xyzo` with `o`) | SEC_TICKETMASTER 2 | pin the facing when a motion probe walks |
//! | `gm:on\|off` | `.gm on\|off` | SEC_GAMEMASTER 3 | see 0649 on why `off` matters |
//!
//! Probe accounts are gmlevel **6**, so every one of these lands. Pass `gear:?` (or `spec:?`) to
//! make the server *list* the templates its class has instead of applying one — the discovery path,
//! since the catalog lives in the world DB and not in any client data.
//!
//! **Two things about `gear:` that have each cost a session.** First, the templates are not all full
//! sets: of the 96 in this deploy, `pvp-r14-hunter-fx` holds 5 items and the priest/warrior
//! `heal-r14`/`tank-r14` hold 8, against a 16.8 mean — so a sparse body can be exactly what the
//! template says, and only a body under `GEAR_FLOOR` is evidence of a refusal. Second, applying gear
//! **strips before it dresses** (`ApplyPremadeGearTemplateToPlayer` unequips all 19 slots, then
//! `StoreNewItemInBestSlots` per item), so re-dressing a body leaves the old copy in its bags and
//! mails the overflow once they fill. Repeated rigs on one character accumulate; a fresh rig-named
//! 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 (docs/METHOD.md) is untouched.
//! `gear:?` or `spec:?` makes the server list its templates for the class, as the catalog lives in
//! the world DB. Applying gear unequips all 19 slots before it equips
//! (`ObjectMgr.cpp:12079-12117`), so re-dressing a body leaves the old set in its bags or mailed;
//! a fresh rig-named character starts with empty bags.
use benilla_protocol::{messages, CharAction, CharCreateReq};
use bevy::prelude::*;
@@ -84,31 +40,24 @@ use crate::net::{
ObjectStore, SelfPlayer,
};
/// Grace after world entry before the first GM line — the descriptor and the UI VM both have to be
/// up, and a command sent into a half-built session is silently dropped.
/// Grace after world entry before the first GM line, while the descriptor and the UI VM come up;
/// a command sent into a half-built session is dropped.
const SETTLE_SECS: f32 = 3.0;
/// Spacing between GM lines. Each one is a server-side mutation whose result the next may depend on
/// (level → gear → spec), and two flips inside one net drain merge to a no-op (0441's lesson).
/// Spacing between GM lines: each may depend on the last (level, gear, spec), and two flips inside
/// one net drain merge to a no-op.
const STEP_SECS: f32 = 0.8;
/// Grace after the last GM line before reading the result back — the level-up, the equip sweep and
/// the teleport all land as descriptor deltas a frame or two later.
/// Grace after the last GM line before reading the result back, as its effects land as descriptor
/// deltas a frame or two later.
const VERIFY_SECS: f32 = 2.0;
/// The fewest items any real gear template in this deploy's world DB actually holds, so a body that
/// wears fewer than this after a `gear:` **cannot** be the template landing — something refused.
///
/// Derived, not invented: `SELECT COUNT(*) … player_premade_item GROUP BY entry` over the 96 rows of
/// `player_premade_item_template` runs 5…23 (mean 16.8). Only three templates are partial —
/// `pvp-r14-hunter-fx` (5), and the priest/warrior `heal-r14`/`tank-r14` (8 each) — so a **low but
/// legitimate** count is a real outcome and this floor deliberately sits under it: the warning fires
/// only for a body that is essentially naked. (Entry 910 has one item row and no template row at
/// all — an orphan; asking for it by entry errors server-side.)
/// The fewest items any gear template in the local world DB holds (`pvp-r14-hunter-fx`, 5), so a
/// body wearing fewer after a `gear:` was refused.
const GEAR_FLOOR: usize = 5;
/// Did a `gear:` ask come back with a body that cannot be wearing the template? `gear:?` is the
/// discovery path — it lists and dresses nothing, so it never counts as a refusal.
/// Whether a `gear:` ask came back with a body that cannot be wearing the template; `gear:?` only
/// lists, so it never counts.
fn gear_was_refused(gear: Option<&str>, equipped: usize) -> bool {
gear.is_some_and(|g| g != "?") && equipped < GEAR_FLOOR
}
@@ -125,9 +74,7 @@ impl Plugin for ProbeRigPlugin {
return; // inert without a parseable spec (parse() has already said why)
};
info!("rig: {}", spec.describe());
// Publish the claim on the character pick as an always-present run fact (decision 1174).
// The roster reads it to know the pick is already spoken for; before 1174 it called
// `rig_char_name_from_env` directly, which is gameplay naming the harness.
// Publish the claim on the character pick, so the roster knows the pick is spoken for.
if let Some(name) = rig_char_name(&spec) {
app.insert_resource(crate::run_mode::RigCharacter(name));
}
@@ -144,10 +91,10 @@ impl Plugin for ProbeRigPlugin {
}
}
/// The parsed `WOW_RIG` spec. Every field is optional — the rig only touches what was asked for.
/// The parsed `WOW_RIG` spec; the rig touches only what was asked for.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct RigSpec {
/// `(race id, class id, gender)` — present together or not at all; they name the character.
/// `(race id, class id, gender)`, which name the character.
body: Option<(u8, u8, u8)>,
level: Option<u8>,
gear: Option<String>,
@@ -155,23 +102,20 @@ struct RigSpec {
/// `.tele <name>`, or `.go xyz` when the token parsed as `map,x,y,z`.
at: Option<String>,
gm: Option<bool>,
/// Whether to `.learn all_myclass`. Defaults to "yes if a level was asked for" — a level-60
/// body with a level-1 spellbook is not a level-60 body.
/// Whether to `.learn all_myclass`; defaults to yes when a level was asked for.
learn: Option<bool>,
}
impl RigSpec {
/// Parse the whitespace-separated, order-free, case-insensitive token soup. Returns `None` (with
/// a `warn!` naming the offending token) rather than silently rigging the wrong thing.
/// Parses the whitespace-separated, order-free, case-insensitive tokens; a bad token warns and
/// rigs nothing.
fn parse(spec: &str) -> Option<Self> {
let mut out = Self::default();
let (mut race, mut class, mut gender) = (None, None, None);
for tok in spec.split_whitespace() {
let lower = tok.to_ascii_lowercase();
if let Some((key, _)) = lower.split_once(':') {
// The KEY is matched case-insensitively, but the VALUE is taken from the original
// token: a premade-template name and a `game_tele` name are both looked up verbatim
// server-side, so lowercasing them would break the lookup.
// The value keeps its case: template and `game_tele` names are looked up verbatim.
let val = tok.split_once(':').map_or("", |(_, v)| v).to_string();
match key {
"gear" => out.gear = Some(val),
@@ -215,14 +159,12 @@ impl RigSpec {
Some(out)
}
/// Whether the class's spellbook should be filled in (`learn:` if given, else "a level implies
/// the spells that come with it").
/// Whether to fill the class's spellbook: as asked, else when a level above 1 was asked for.
fn wants_spells(&self) -> bool {
self.learn.unwrap_or(self.level.is_some_and(|l| l > 1))
}
/// The one-line echo of what was asked for — printed at startup so a mis-parse is obvious
/// before the socket even opens.
/// The one-line echo of the spec, printed at startup.
fn describe(&self) -> String {
let body = self
.body
@@ -275,9 +217,8 @@ enum RigPhase {
Settling(f32),
/// Sending the batch, one line per [`STEP_SECS`].
Commanding,
/// The batch is out; re-read the descriptor at this `Time::elapsed_secs` and report what
/// actually landed. A command the server refused is otherwise invisible — the send always
/// "succeeds", and the preflight banner describes the body we *arrived* in, not the rigged one.
/// The batch is out; at this `Time::elapsed_secs` the descriptor is re-read to report what
/// landed, since a refused GM command gets no error.
Verifying(f32),
Done,
}
@@ -286,9 +227,9 @@ enum RigPhase {
struct Rig {
spec: RigSpec,
phase: RigPhase,
/// The freshest roster (each successful create/delete is preceded by a new enum).
/// The freshest roster.
roster: Vec<benilla_protocol::Character>,
/// A server-limit eviction has already been spent — one is a full roster, two is a bug.
/// A server-limit eviction has been spent; only one is allowed.
evicted: bool,
steps: Vec<String>,
sent: usize,
@@ -315,11 +256,10 @@ fn drive_rig(
match rig.phase {
RigPhase::AwaitRoster => {
if rig.roster.is_empty() && rig.spec.body.is_none() {
return; // no roster yet, and nothing to create — wait for the enum
return; // no roster yet, and nothing to create
}
let Some(want) = rig_char_name(&rig.spec) else {
// No body asked for: let char_select's own WOW_CHAR/default pick stand, and just
// configure whatever walks into the world.
// No body asked for: char_select's own pick stands.
rig.phase = RigPhase::Entering;
return;
};
@@ -444,8 +384,7 @@ fn drive_rig(
maxhp = store.0.unit_max_health().unwrap_or(0),
faction = store.0.unit_faction_template().unwrap_or(0),
);
// The one failure that reads as success: the batch went out, the server refused every
// line, and the body is exactly as found. Level is the cheapest tell.
// A batch the server refused leaves the body as found; the level is the cheapest tell.
if let Some(want) = rig.spec.level {
let got = store.0.unit_level().unwrap_or(0);
if got < u32::from(want) {
@@ -457,13 +396,8 @@ fn drive_rig(
);
}
}
// The other failure that reads as success: `.character premade gear` went out, the body
// came back wearing almost nothing, and every later reading is of the wrong body. The
// server unequips all 19 slots *before* it equips (`ApplyPremadeGearTemplateToPlayer` →
// `AutoUnequipItemFromSlot` → `StoreNewItemInBestSlots`, vmangos `ObjectMgr.cpp`), so a
// near-naked body means the equip half was refused while the strip half already ran —
// the set is in the bags, or mailed if they were full. A session that misses this line
// measures a probe in its underwear.
// The server unequips all 19 slots before it equips (`ObjectMgr.cpp:12079-12117`), so
// a near-naked body means the equip half was refused after the strip ran.
if gear_was_refused(rig.spec.gear.as_deref(), equipped) {
warn!(
"rig: asked for gear but the body wears only {equipped} item(s) — the leanest \
@@ -479,9 +413,8 @@ fn drive_rig(
}
}
/// The GM batch, in the one order that works: revive first (a ghost half-applies everything else),
/// level before gear (a premade template only levels *up*), spells after the level they belong to,
/// and the teleport last so a fixup lands where you asked rather than where you started.
/// The GM batch in the order that works: revive first (a ghost half-applies the rest), level
/// before gear (a template only levels up), spells after the level, the teleport last.
fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
let mut steps = Vec::new();
if dead {
@@ -498,8 +431,7 @@ fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
}
for (token, verb) in [(&spec.gear, "gear"), (&spec.spec, "spec")] {
if let Some(t) = token {
// `?` asks the server to LIST this class's templates instead of applying one — the
// catalog lives in the world DB, so the server is the only thing that can enumerate it.
// `?` asks the server to list this class's templates instead of applying one.
let arg = if t == "?" { "" } else { t.as_str() };
steps.push(format!(".character premade {verb} {arg}").trim_end().into());
}
@@ -514,12 +446,9 @@ fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
steps
}
/// `map,x,y,z[,o]` → `.go xyz x y z map`, or `.go xyzo x y z o map` when a facing is given
/// (VERIFIED vmangos `Chat.cpp:408`: `xyzo`, SEC_TICKETMASTER like `xyz`). Anything else is a
/// `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-checkout A/B before 1462's sitting caught it.
/// `map,x,y,z[,o]` for `.go xyz x y z map`, or `.go xyzo x y z o map` with a facing (vmangos
/// `Chat.cpp:408`); anything else is a `game_tele` name. A motion probe pins the facing, since
/// `W` follows the body's facing and a character keeps whatever facing it last had.
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()?;
@@ -544,27 +473,16 @@ fn rig_char_name(spec: &RigSpec) -> Option<String> {
class_code(class)?,
if gender == 1 { "f" } else { "" }
);
// vmangos caps player names at 12 (`MAX_PLAYER_NAME`); the longest this can build is
// `Nel` + `Wlk` + `three` + `f` = 12. Normalized like the server does: leading capital, rest
// lower.
// vmangos caps player names at 12 (`ObjectMgr.h:398`); the longest built here is
// `Nel` + `Wlk` + `three` + `f`. Normalized as the server does: leading capital, rest lower.
let mut chars = name.chars();
Some(chars.next()?.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase())
}
/// 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 `word` — the probe body itself, a hand-made character,
/// another checkout's leftovers — is invisible here, and that is what makes automatic deletion safe.
///
/// `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.
/// This checkout's rig-named character cheapest to rebuild: lowest level first, ties to the oldest
/// (the enum is ordered by `create_time`, vmangos `CharacterHandler.cpp:180`). Anything not a rig
/// name for `word` is never chosen. `word` is a parameter so a test does not depend on the
/// checkout it runs in.
fn evictable(
roster: &[benilla_protocol::Character],
want: Option<&str>,
@@ -592,8 +510,6 @@ fn is_rig_name(name: &str, word: &str) -> bool {
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,
/// and readable in a roster line.
const RACE_CODES: [(u8, &str); 8] = [
(1, "hum"),
(2, "orc"),
@@ -605,7 +521,7 @@ const RACE_CODES: [(u8, &str); 8] = [
(8, "tro"),
];
/// Class id → the 3-letter name code (`wlk` for warlock — `war` is already the warrior's).
/// Class id to its 3-letter name code (`wlk` for warlock, as `war` is the warrior's).
const CLASS_CODES: [(u8, &str); 9] = [
(1, "war"),
(2, "pal"),
@@ -626,8 +542,7 @@ fn class_code(id: u8) -> Option<&'static str> {
CLASS_CODES.iter().find(|(i, _)| *i == id).map(|(_, c)| *c)
}
/// Spec word → `ChrRaces.dbc` id. Both the one-word and the two-word spellings, because a spec is
/// typed by hand and `nightelf`/`night-elf` are the same intent.
/// Spec word to `ChrRaces.dbc` id, with the common spellings.
fn race_id(word: &str) -> Option<u8> {
Some(match word {
"human" => 1,
@@ -642,7 +557,7 @@ fn race_id(word: &str) -> Option<u8> {
})
}
/// Spec word → `ChrClasses.dbc` id (6 and 10 are unused in 1.12).
/// Spec word to `ChrClasses.dbc` id (6 and 10 are unused in 1.12).
fn class_id(word: &str) -> Option<u8> {
Some(match word {
"warrior" => 1,
@@ -664,8 +579,7 @@ mod tests {
#[test]
fn a_naked_body_after_a_gear_ask_is_a_refusal_but_a_lean_template_is_not() {
// The three partial templates this deploy really ships (5 and 8 items) must stay quiet —
// the floor exists to catch a stripped body, not to second-guess the world DB.
// The partial templates (5 and 8 items) must stay quiet.
assert!(!gear_was_refused(Some("pvp-r14-hunter-fx"), 5));
assert!(!gear_was_refused(Some("tank-r14"), 8));
assert!(
@@ -689,8 +603,7 @@ mod tests {
#[test]
fn template_names_keep_their_case() {
// The token soup is matched case-insensitively, but a premade template name and a
// `game_tele` name are looked up VERBATIM server-side — lowercasing them would break both.
// Template and `game_tele` names are looked up verbatim server-side.
let s = RigSpec::parse("at:ThunderBluff gear:DPS-PreRaid-BiS").unwrap();
assert_eq!(s.at.as_deref(), Some("ThunderBluff"));
assert_eq!(s.gear.as_deref(), Some("DPS-PreRaid-BiS"));
@@ -698,12 +611,11 @@ mod tests {
#[test]
fn a_half_named_body_is_refused_rather_than_guessed() {
// A race with no class (or the reverse) cannot name a character, and guessing the other
// half would silently rig the wrong body.
// A race with no class, or the reverse, cannot name a character.
assert!(RigSpec::parse("tauren 60").is_none());
assert!(RigSpec::parse("druid").is_none());
assert!(RigSpec::parse("tauren druid").is_some());
// No body at all is legal — that rigs whatever already logs in.
// No body at all rigs whatever already logs in.
assert_eq!(RigSpec::parse("60 gear:x").unwrap().body, None);
// A typo is refused, never ignored.
assert!(RigSpec::parse("taruen druid").is_none());
@@ -784,9 +696,7 @@ mod tests {
};
assert_eq!(name("tauren druid", "one"), "taudruone");
assert_eq!(name("nightelf warlock female", "three"), "nelwlkthreef");
// The longest name this scheme can mint is exactly vmangos's MAX_PLAYER_NAME of 12 — a
// 13th character would be refused by the server as an invalid name, and the rig would
// recreate it every run.
// The longest name this scheme can mint is exactly vmangos's `MAX_PLAYER_NAME`.
const MAX_PLAYER_NAME: usize = 12;
let longest = RACE_CODES
.iter()
@@ -836,17 +746,15 @@ mod tests {
#[test]
fn eviction_spends_the_body_that_is_cheapest_to_rebuild() {
// 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`).
// The word is passed in, never read from the checkout the test runs in.
let slot = "one";
// Roster order IS create order (vmangos enumerates by `create_time`).
// Roster order is create order (vmangos enumerates by `create_time`).
let roster = [
row("Probeone", 60, 1), // the identity — never evictable
row("Watcher", 60, 2), // hand-made — never evictable
row("Probeone", 60, 1), // the identity, never evictable
row("Watcher", 60, 2), // hand-made, never evictable
row("Taudruone", 60, 3), // oldest rig body, but a geared 60
row("Orcwarone", 1, 4), // a level-1 filler: the cheapest to lose
row("Undmagone", 1, 5), // same level, but younger — the tie goes to the older
row("Undmagone", 1, 5), // same level, but younger: the tie goes to the older
row("Nelwlkonef", 40, 6),
];
assert_eq!(
@@ -858,10 +766,9 @@ mod tests {
evictable(&roster, Some("Orcwarone"), slot),
Some((5, "Undmagone".into())),
);
// Nothing rig-named on this slot ⇒ nothing to evict; the caller errors rather than guessing.
// 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 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.
// Another checkout's rig characters are invisible.
assert_eq!(evictable(&roster, None, "three"), None);
}
+67 -137
View File
@@ -1,17 +1,9 @@
//! The **NPC-service ladder** live probe (`WOW_PROBE_SERVICE=1`) — decision 1861's end-to-end
//! instrument, and the answer to "does right-clicking a trainer or a quest giver really open their
//! window instead of a gossip menu?".
//!
//! 1861 replaced a cursor-kind dispatch with the reference's own first-match-wins walk over
//! `UNIT_NPC_FLAGS` ([`crate::target::click::service_arm`]; `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
//! wire, and what appears on screen is the *server's* answer to the opcode the ladder picked.
//!
//! So the probe walks four real Northshire/Stormwind NPCs, one per interesting flag shape, and for
//! each one reports the wire's `UNIT_NPC_FLAGS`, the arm the shipped ladder takes, the opcode it
//! sends, and which window actually opened:
//! The NPC-service live probe (`WOW_PROBE_SERVICE=1`): does right-clicking a trainer or a quest
//! giver open their window rather than a gossip menu? The click walks `UNIT_NPC_FLAGS` first
//! match wins ([`crate::target::click::service_arm`]; `0x5f0130`), bit 0 (GOSSIP) first, so a
//! trainer or questgiver that also carries GOSSIP opens a gossip menu. The probe hops to four real
//! NPCs, calls the shipped `service_arm`/`service_action` on the wire's flags and
//! [`QuestGiver::status`], and reports the arm, the opcode and the window that opened:
//!
//! | NPC | `npc_flags` | arm | window |
//! |---|---|---|---|
@@ -20,37 +12,16 @@
//! | *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 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.
//! The pure-questgiver row is found by predicate: the bit-1 arm is gated on the quest status
//! (`[unit+0xcb8] ∉ {0,1}`), which depends on what the character has turned in, so the leg takes
//! the first streamed unit with QUESTGIVER, no GOSSIP and an offer, and SKIPs if there is none.
//! Each leg also checks that the re-click gate arms with the window. A last leg forks the pure
//! vendor: an empty cursor opens the list, a held item sells.
//!
//! **The ladder is called, never re-implemented.** The probe reads the same two inputs the click
//! reads (`ObjectStore::unit_npc_flags` and [`QuestGiver::status`]) and calls the shipped
//! `service_arm`/`service_action`; only the mouse hit-test and the cursor's range gray sit outside
//! it. A private copy of the bit table is exactly how a probe goes quietly stale (the B249 icon
//! map), so there isn't one.
//!
//! **The pure-questgiver row is found by predicate, not by entry.** Whether Deputy Willem has
//! anything for *this* probe character depends on what it has already turned in, and the bit-1 arm
//! is gated on that status (`[unit+0xcb8] ∉ {0,1}`). So that leg hops into Northshire and takes the
//! first streamed unit that is QUESTGIVER, not GOSSIP, and actually has an offer — reporting which
//! one it picked. If the character has cleared the whole valley it SKIPs and says so.
//!
//! One `PROBE_SERVICE: <leg> PASS/FAIL/SKIP <detail>` line per leg, then a final
//! `PROBE_SERVICE: DONE pass=<n> fail=<m> skip=<k>`. A wrong arm or a wrong window is a FAIL; an
//! environmental problem (the `.go` refused, the NPC never streamed, no offer left in the valley)
//! is a SKIP with the reason.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_DATA=WoW/Data WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
//! WOW_UNATTENDED=1 WOW_PROBE_SERVICE=1 cargo run -q -p benilla
//! ```
//! (the 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).
//! One `PROBE_SERVICE: <leg> PASS/FAIL/SKIP <detail>` line per leg, then `PROBE_SERVICE: DONE
//! pass=<n> fail=<m> skip=<k>`. A wrong arm or window FAILs; an environmental problem SKIPs.
//! Non-combat; each window is closed client-side, as the reference's own close is (1.12 has no
//! `CMSG_GOSSIP_CLOSE`). The switches are `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::prelude::*;
@@ -69,56 +40,38 @@ use crate::ui_quest::QuestGiver;
use crate::ui_session::InteractNpc;
use crate::ui_trainer::TrainerOpen;
/// How wide to look around a `.go` landing for the leg's NPC — **and no wider than the reference's
/// own service reach**, because the click cannot act past it either: beyond
/// [`crate::target::SERVICE_RANGE_SQ`] (5.5556 yd, `0xc4c28c`/`0xb4b32c`) the cursor goes gray, the
/// click sends nothing, and vmangos's `GetNPCIfCanInteractWith` refuses the opcode silently.
///
/// This constant is the reason the questgiver leg first read as a client defect: its hop point was
/// an invented Northshire "hub" ~29 yd from Deputy Willem, so the probe called a ladder the click
/// would never have reached, the server dropped the packet on its own distance check, and the
/// timeout printed a FAIL about the client (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.
/// How wide to look around a `.go` landing: the reference's service reach,
/// [`crate::target::SERVICE_RANGE_SQ`] (5.5556 yd, `0xc4c28c`/`0xb4b32c`). Past it the cursor goes
/// gray, the click sends nothing, and vmangos's `GetNPCIfCanInteractWith` refuses silently.
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
/// Let the hop land and the tile stream before the first scan.
const SETTLE_SECS: f64 = 3.0;
/// How long a leg waits for its NPC to stream in before calling the hop environmental.
const SCAN_TIMEOUT_SECS: f64 = 20.0;
/// How long a leg waits for its window after the opcode goes out. Generous for the same reason the
/// binder probe's is: the observer is starved during a terrain load, never the wire.
/// How long a leg waits for its window after the opcode goes out; a terrain load starves the
/// observer, not the wire.
const WINDOW_TIMEOUT_SECS: f64 = 20.0;
/// How long the re-click gate's latch may lag the window it belongs to.
///
/// It no longer lags by construction: `feed_interact_npc` is seated after the net apply since
/// decision 2022, so the token arms on the very frame the window opens. It USED to be allowed to
/// lag a frame (`ui_session` was "deliberately unordered" against the apply, on decision 1741's
/// reading that a window's first frame is invisible) — and "one frame" and "never" are the same
/// reading if you sample once, which is exactly what the first run of this assert did: it failed
/// the quest and trainer legs, whose windows open on the packet, and passed the gossip legs only
/// because the gossip frame holds shut for several frames waiting on its greeting query (B292).
/// The poll stays for the other reason a single sample lies — a starved observer during a terrain
/// load — and prints how long the latch actually took.
/// How long the re-click gate's latch may lag its window. `feed_interact_npc` runs after the net
/// apply, so the token arms on the window's own frame; the poll covers a starved observer and
/// reports the lag.
const GATE_TIMEOUT_SECS: f64 = 3.0;
/// **The vendor fork's leg** (decision 1914) — Brother Danil, `creature_template.entry = 152`,
/// `npc_flags = 4` (VENDOR and nothing else, so the ladder cannot reach him by any other arm),
/// spawned in Northshire at his own `creature.position_*` (live-DB verified this session).
/// The vendor fork's NPC: Brother Danil (entry 152), `npc_flags` 4, vendor only, so no other arm
/// reaches him; at his own Northshire spawn.
const VENDOR_ENTRY: u32 = 152;
const VENDOR_AT: [f32; 3] = [-8901.59, -112.716, 82.0314];
const VENDOR_MAP: u32 = 0;
/// Tough Jerky — a 1-copper vendor trash item, `.additem`'d so the sell leg has something of its
/// own to sell and never touches whatever the probe character was already carrying.
/// Tough Jerky, a 1-copper item `.additem`ed so the sell leg sells only its own copy.
const SELL_ITEM_ENTRY: u32 = 117;
/// How long to wait for `.additem` to land in a bag, and for the sold item to leave it.
const BAG_TIMEOUT_SECS: f64 = 20.0;
/// Which NPC a leg is looking for.
enum Ident {
/// A specific `creature_template.entry` — the identity check that cannot be confused by a
/// neighbour who happens to share a flag.
/// A specific `creature_template.entry`, which a neighbour sharing a flag cannot match.
Entry(u32),
/// The bit-1 arm's own shape: QUESTGIVER set, GOSSIP clear, and an offer live for THIS
/// character (`SMSG_QUESTGIVER_STATUS` ∉ {0,1}) — see the module doc.
/// The bit-1 arm's shape: QUESTGIVER set, GOSSIP clear, and an offer live for this character
/// (`SMSG_QUESTGIVER_STATUS` ∉ {0,1}).
PureQuestgiverWithOffer,
}
@@ -136,20 +89,17 @@ struct Leg {
name: &'static str,
ident: Ident,
map: u32,
/// The spawn points to try, in order — each an NPC's own `creature.position_*`, so the landing
/// is inside the service reach. A leg with several is one whose subject depends on what this
/// character has already turned in (the questgiver leg); it moves to the next candidate when
/// the one it hopped to has nothing to offer.
/// The spawn points to try, in order, each an NPC's own position so the landing is inside the
/// service reach; the next is tried when one has nothing to offer.
at: &'static [[f32; 3]],
/// `creature_template.npc_flags` as the world DB held it when this probe was written — printed
/// beside the wire's value so a template edit reads as a note rather than a mystery.
/// `creature_template.npc_flags` in the world DB, printed beside the wire's value.
db_flags: u32,
want_arm: ServiceArm,
want_window: Window,
}
/// The four flag shapes worth a live reading (module doc's table). Northshire first — three of the
/// four legs share one hop's worth of terrain — then the Stormwind first-aid trainer.
/// The four flag shapes worth a live reading (the module doc's table): Northshire, then the
/// Stormwind first-aid trainer.
const LEGS: &[Leg] = &[
Leg {
name: "Marshal McBride — GOSSIP|QUESTGIVER",
@@ -173,8 +123,8 @@ const LEGS: &[Leg] = &[
name: "a pure questgiver with an offer — QUESTGIVER, no GOSSIP",
ident: Ident::PureQuestgiverWithOffer,
map: 0,
// Deputy Willem (823), Eagan Peltskinner (196), Falkhaan Isenstrider (6774) — the three
// pure questgivers of Northshire, each at its own spawn (live-DB verified this session).
// Northshire's three pure questgivers at their own spawns: Deputy Willem, Eagan
// Peltskinner and Falkhaan Isenstrider (entries 823, 196, 6774).
at: &[
[-8933.54, -136.523, 83.4466],
[-8869.22, -163.237, 80.9719],
@@ -210,14 +160,10 @@ struct ServiceProbe {
passes: u32,
fails: u32,
skips: u32,
/// How many frames the current scan actually got to poll in. A starved observer and a missing
/// NPC look identical in a SKIP line otherwise — and on a loaded machine the first is far more
/// likely: at load 61 this probe logged `frame hitch: ~1010 ms` on repeat (the ~1 fps regime
/// decisions 0713/0777/1355 name) and its 20-second scan window bought about twenty samples.
/// 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.
/// How many frames the current scan got to poll in, so a SKIP tells a starved observer from a
/// missing NPC.
polls: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
/// Latched once [`Phase::Done`] has fired its exit.
exited: bool,
}
@@ -238,19 +184,18 @@ enum Phase {
since: f64,
guid: u64,
},
/// The window is open; waiting for the re-click gate's `"npc"` token to name its NPC
/// (decision 1905). Its own phase because the latch is allowed to lag the window by a frame —
/// see [`GATE_TIMEOUT_SECS`].
/// The window is open; waiting for the re-click gate's `"npc"` token to name its NPC (see
/// [`GATE_TIMEOUT_SECS`]).
Gate {
i: usize,
since: f64,
guid: u64,
},
/// The vendor fork's own chain (decision 1914) — `.go` to the pure vendor.
/// The vendor fork's own chain: `.go` to the pure vendor.
VendorHop {
sent_at: f64,
},
/// The EMPTY-cursor leg: `CMSG_LIST_INVENTORY` sent, waiting for the merchant window.
/// The empty-cursor leg: `CMSG_LIST_INVENTORY` sent, waiting for the merchant window.
VendorOpen {
since: f64,
guid: u64,
@@ -260,7 +205,7 @@ enum Phase {
since: f64,
guid: u64,
},
/// The HELD-cursor leg: `CMSG_SELL_ITEM` sent, waiting for the slot to empty.
/// The held-cursor leg: `CMSG_SELL_ITEM` sent, waiting for the slot to empty.
VendorSell {
since: f64,
bag: i64,
@@ -269,9 +214,7 @@ enum Phase {
Done,
}
/// The bag slot holding [`SELL_ITEM_ENTRY`], as `"bag,slot"`, or `""` — asked of the live VM
/// through the same bindings a player's bag UI uses, so the probe never needs its own view of the
/// container fields.
/// The bag slot holding [`SELL_ITEM_ENTRY`], asked of the live VM through the bag UI's bindings.
fn find_item_slot(script: &UiScript) -> Option<(i64, u32)> {
let found = script
.eval::<String>(&format!(
@@ -288,8 +231,7 @@ fn find_item_slot(script: &UiScript) -> Option<(i64, u32)> {
Some((b.parse().ok()?, s.parse().ok()?))
}
/// A flag word as the log wants it — the hex plus the bit names, so a reader never has to decode
/// `0x13` by hand.
/// A flag word as hex plus bit names.
fn flag_names(flags: u32) -> String {
const NAMES: [(u32, &str); 14] = [
(f::GOSSIP, "GOSSIP"),
@@ -318,15 +260,15 @@ fn flag_names(flags: u32) -> String {
}
}
/// Which window is open on `guid`, if any — the observation every leg is judged on.
/// Which window is open on `guid`, if any.
fn open_window(
guid: u64,
gossip: &GossipState,
giver: &QuestGiver,
trainer: &TrainerOpen,
) -> Option<Window> {
// The gossip frame does not open on the packet: it holds closed until the greeting's
// `CMSG_NPC_TEXT_QUERY` answers (B292's hold, `ui_gossip`), so the greeting is part of "open".
// The gossip frame stays closed until the greeting's `CMSG_NPC_TEXT_QUERY` answers
// (`ui_gossip`), so the greeting is part of open.
if gossip.npc == Some(guid) && gossip.greeting.is_some() {
return Some(Window::Gossip);
}
@@ -345,13 +287,10 @@ fn service_probe(
mut gossip: ResMut<GossipState>,
mut giver: ResMut<QuestGiver>,
mut trainer: ResMut<TrainerOpen>,
// `[0xb4e2d0]`'s mirror — what the re-click gate reads (decision 1905). The probe asserts it
// live because the gate's risk is never the `==`, it is whether this really is armed by a
// window and only by a window.
// `[0xb4e2d0]`'s mirror, what the re-click gate reads: armed by a window and only by one.
interact: Res<InteractNpc>,
// The vendor fork's leg (decision 1914): the window the empty-cursor leg must open, the VM the
// held-cursor leg picks an item up in, and the two the app needs to turn a (bag, slot) into
// the guid the wire addresses.
// The vendor fork: the merchant window, the VM the held-cursor leg picks up in, and what
// turns a (bag, slot) into the item guid.
mut merchant: ResMut<MerchantOpen>,
script: Option<NonSendMut<UiScript>>,
objects: crate::net::Objects,
@@ -380,7 +319,7 @@ fn service_probe(
let me = player.pos;
let found = units.iter().find(|(guid, net_e, store, tf)| {
// The click's own gate: `dist² > SERVICE_RANGE_SQ` is the cursor's `unable`, and
// the arm never runs. Acting outside it would measure nothing.
// the arm never runs.
if net_e.kind != EntityKind::Unit
|| tf.translation.distance_squared(me) > SCAN_RANGE_SQ
{
@@ -421,8 +360,7 @@ fn service_probe(
SCAN_RANGE_SQ.sqrt(),
leg.at.len(),
match fps < 5.0 {
// The reading is about the OBSERVER, not the world: below a handful of
// frames a second this leg never really looked (0713/0777/1355).
// Below a handful of frames a second the leg never really looked.
true =>
" — THE OBSERVER WAS STARVED, so this SKIP says nothing about \
the NPC. Re-run on an idle machine (`uptime`; a leg runner's guard \
@@ -487,11 +425,8 @@ fn service_probe(
giver.clear();
trainer.clear();
// **The first click must always get through** (decision 1905). Nothing is open here —
// this leg has just cleared, and the reference arms `[0xb4e2d0]` from window openers
// only — so the re-click gate must read disarmed at the moment of the send. If it ever
// reads armed here, the gate would be eating first clicks and this probe would still
// pass every other leg, which is why the assert is at the send and not after it.
// The first click must get through: nothing is open, and the reference arms
// `[0xb4e2d0]` only from window openers, so the gate must read disarmed at the send.
if interact.1.is_some() {
error!(
"PROBE_SERVICE: FAIL ({}) — the npc token is armed on {:#x} BEFORE the send; \
@@ -504,8 +439,7 @@ fn service_probe(
return;
}
// `None` = an empty cursor: no leg here holds an item, and the vendor arm's fork is
// the merchant probe's business, not this one's (decision 1914).
// `None` = an empty cursor; the vendor fork's held-cursor leg runs after the ladder.
let sent = match service_action(arm, guid, false, None) {
ServiceAction::Send(cmd) => {
let named = format!("{cmd:?}");
@@ -587,9 +521,8 @@ fn service_probe(
next(&mut probe, &net, i, now);
}
None if now - since > WINDOW_TIMEOUT_SECS => {
// Distinguish the one near-miss that is a real defect from a dead wire: the
// gossip session latched but its greeting never resolved, so the frame stayed
// shut (`GossipState::resolve_greeting`'s "Missing gossip text!" path).
// A gossip session latched with an unresolved greeting keeps the frame shut
// (`GossipState::resolve_greeting`'s "Missing gossip text!" path).
let held = gossip.npc == Some(guid) && gossip.greeting.is_none();
error!(
"PROBE_SERVICE: FAIL ({}) — no {:?} window on {guid:#x} within \
@@ -633,7 +566,7 @@ fn service_probe(
next(&mut probe, &net, i, now);
}
}
// ── The vendor fork (decision 1914) ──────────────────────────────────────────────────
// ── The vendor fork ──────────────────────────────────────────────────
Phase::VendorHop { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
@@ -662,8 +595,8 @@ fn service_probe(
trainer.clear();
merchant.clear();
// The EMPTY-cursor leg. The arm is asserted, not assumed: a vendor who had grown a
// GOSSIP bit would take the gossip arm and this leg would be measuring nothing.
// The empty-cursor leg. The arm is asserted: a vendor with a GOSSIP bit takes the
// gossip arm.
let arm = service_arm(flags, None);
if arm != Some(ServiceArm::Vendor) {
warn!(
@@ -738,7 +671,7 @@ fn service_probe(
}
return;
};
// Pick it up exactly as a player does — through the binding, not by writing the model.
// Pick it up as a player does, through the binding, not by writing the model.
if let Err(e) = script.run(&format!("PickupContainerItem({bag}, {slot})")) {
error!("PROBE_SERVICE: SKIP (vendor fork, held cursor) — PickupContainerItem: {e}");
probe.skips += 1;
@@ -809,8 +742,7 @@ fn service_probe(
probe.phase = Phase::Done;
return;
};
// The server's own verdict: the sold stack leaves the bag. Nothing client-side can
// fake this — it is the inventory update answering the packet.
// The server's verdict: the sold stack leaves the bag.
let gone = script
.eval::<bool>(&format!(
"return GetContainerItemLink({bag}, {slot}) == nil"
@@ -843,9 +775,8 @@ fn service_probe(
"PROBE_SERVICE: DONE pass={} fail={} skip={}",
probe.passes, probe.fails, probe.skips
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
// teardown hang cannot leave a client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
@@ -856,8 +787,7 @@ fn service_probe(
}
}
/// The open quest panel's shape, for the PASS line — `Greeting` (the multi-quest list) vs
/// `Detail` (the single quest's own frame) is exactly the distinction a reader wants here.
/// The open quest panel's shape, for the PASS line.
fn discr(view: &crate::ui_quest::QuestView) -> &'static str {
use crate::ui_quest::QuestView as V;
match view {
+34 -72
View File
@@ -1,56 +1,28 @@
//! The meeting-stone live probe (`WOW_PROBE_STONE=1`) — decision 2283's instrument: **can a
//! player actually get into the LFG queue, and does everything downstream of that light up?**
//! The meeting-stone live probe (`WOW_PROBE_STONE=1`): joins a real meeting stone's LFG queue and
//! reads the answer out of the live VM. A stone is joined with `CMSG 0x292`; vmangos ignores a
//! `CMSG_GAMEOBJ_USE` on type 23 (`GameObject.cpp:1836`).
//!
//! Until 2283 a right-click on a meeting stone sent `CMSG_GAMEOBJ_USE`, which vmangos' own
//! `GameObject::Use` answers with an explicit `return` for type 23. So the whole receiving half —
//! the two globals, the five-way status table, `MEETINGSTONE_CHANGED`, the stock
//! `MiniMapMeetingStoneFrame` — had been built and tested against synthetic packets and had never
//! once run off a real one. This probe is what closes that: it drives the click's own route and
//! then reads the answer out of the **live VM**, not out of our own state.
//!
//! **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 (`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
//! for real — the validator, `CMSG 0x292`, the server, `SMSG 0x295`, the feed, the two globals,
//! the stock frame. It does **not** cover the mouse hit-test or the shared gates *above* the seam
//! (highlightable, mounted, range); those are the click test's
//! It writes the [`MeetingStoneUse`] the click ladder writes, so everything downstream runs for
//! real (the validator, `CMSG 0x292`, `SMSG 0x295`, the feed, the two globals, the stock frame).
//! The hit-test and the gates above that seam are the click test's
//! (`target::click::tests::a_right_click_on_a_meeting_stone_joins_it_and_sends_no_gameobj_use`).
//! Neither alone closes the chain; together they do.
//!
//! ## The five legs, in order
//! The five legs, in order:
//!
//! 1. **CONTROL** — parked at the stone, nothing clicked: `IsInMeetingStoneQueue()` must be `nil`
//! and the stock icon hidden. A probe that only ever sees the queued state cannot tell a fix
//! from a stuck flag.
//! 2. **REFUSED** — the body demoted **below** the stone's `data[0]`, then clicked: the client's
//! own level refusal must fire, and **nothing** may reach the wire. This is the one of the four
//! refusals the server does not duplicate — `HandleMeetingStoneJoinOpcode` never looks at the
//! stone's level band — so if our copy is wrong, the server queues a level-1 character for
//! Deadmines and nobody notices.
//! 3. **JOINED** — the body levelled **into** the band, then clicked: `SMSG 0x295` must arrive with
//! this stone's own `data[2]`, `IsInMeetingStoneQueue()` must answer the number `1`,
//! `GetMeetingStoneStatusText()` a real string, and `MiniMapMeetingStoneFrame` must be shown.
//! 4. **DARKENED** — with that queue held, the stone's own `highlightable` slot must now refuse it
//! ([`crate::target::cursor_mode::meeting_stone_queued`] over the live area): the stone you are
//! queued at stops being clickable, which is the whole of `0x5f6990`.
//! 5. **LEFT** — `CancelMeetingStoneRequest()` through the live VM: back to `nil`, icon hidden.
//! 1. Control: parked, nothing clicked: `IsInMeetingStoneQueue()` is `nil`, the icon hidden.
//! 2. Refused: demoted below the stone's `data[0]`, then clicked: the client's level refusal fires
//! and nothing reaches the wire. The server never checks the level band
//! (`LFGHandler.cpp:31`), so this refusal is the client's alone.
//! 3. Joined: levelled into the band, then clicked: `SMSG 0x295` carries the stone's `data[2]`,
//! `IsInMeetingStoneQueue()` is `1`, `GetMeetingStoneStatusText()` a string, and
//! `MiniMapMeetingStoneFrame` shown.
//! 4. Darkened: the stone queued at is no longer highlightable
//! ([`crate::target::cursor_mode::meeting_stone_queued`], `0x5f6990`).
//! 5. Left: `CancelMeetingStoneRequest()` through the VM: back to `nil`, icon hidden.
//!
//! ## The run recipe
//!
//! ```text
//! 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 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
//! other), and a probe character's level is scratch. Grep `PROBE_STONE:` for the verdict; the
//! probe self-exits when it lands.
//! `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]` aims it elsewhere. It leaves the body at the stone's
//! minimum level. Grep `PROBE_STONE:` for the verdict; the switches are `docs/CONTRIBUTING.md`,
//! "Running it unattended".
use bevy::prelude::*;
@@ -64,24 +36,20 @@ use crate::player::Player;
use crate::target::cursor_mode::meeting_stone_queued;
use crate::ui_dialog_verbs::{MeetingStone, MeetingStoneUse};
/// The probe's default object: the **Stockade** meeting stone in Stormwind (`gameobject.guid`
/// 26635, template 179595 — `data[0..2] = 24, 32, 717`). Chosen because it is a plain unpooled
/// spawn in a capital, its level band is narrow enough that a level-1 body is comfortably outside
/// it, and area 717 resolves to a real `AreaTable` row, so the status text comes out a sentence
/// ("Looking for more for The Stockade") rather than the `UNKNOWN` fallback — which makes the
/// JOINED leg's `text=yes` mean the whole rebuild ran, not just that a string exists.
/// The default object: the Stockade meeting stone in Stormwind (`gameobject.guid` 26635,
/// template 179595, `data[0..2] = 24, 32, 717`), an unpooled spawn whose area 717 is a real
/// `AreaTable` row, so the status text is a sentence rather than the `UNKNOWN` fallback.
const STONE_AT: [f32; 4] = [-8810.5, 798.0, 98.2, 0.0];
/// `GAMEOBJECT_TYPE_MEETINGSTONE`.
const GO_TYPE_MEETINGSTONE: i32 = 23;
/// The level leg 2 demotes to — below every shipped stone's `data[0]`.
/// The level leg 2 demotes to, below every shipped stone's `data[0]`.
const LEVEL_BELOW: u32 = 1;
const SCAN_RANGE: f32 = 30.0;
const SETTLE_SECS: f64 = 6.0;
const SCAN_TIMEOUT_SECS: f64 = 30.0;
const LEVEL_TIMEOUT_SECS: f64 = 20.0;
/// How long a refusal is given to *not* happen. The join round trip below is the same server, so
/// anything that was going to arrive has arrived.
/// How long the refused click is watched for a reply that must not come.
const REFUSAL_WINDOW_SECS: f64 = 6.0;
const QUEUE_TIMEOUT_SECS: f64 = 20.0;
@@ -108,7 +76,7 @@ struct StoneProbe {
tmpl: Option<MeetingStoneTemplate>,
fails: u32,
exited: bool,
/// What each leg actually read, for the DONE line.
/// What each leg read, for the DONE line.
joined_area: u32,
status_text: Option<String>,
icon_shown: bool,
@@ -127,9 +95,8 @@ enum Phase {
since: f64,
},
Control,
/// `.character level` issued; waiting for the store to report it. `join` says which leg the
/// click that follows belongs to — **not** the level value, because a stone whose `data[0]`
/// is 1 would make the two indistinguishable and loop the probe forever.
/// `.character level` issued; waiting for the store to report it. `join` names the next
/// click's leg, since a stone whose `data[0]` is 1 makes the two levels equal.
WaitLevel {
want: u32,
since: f64,
@@ -151,8 +118,7 @@ enum Phase {
Done,
}
/// Where the probe is aimed: `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]`, else [`STONE_AT`]. Anything
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
/// `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]`, else [`STONE_AT`] (the common value is `1`).
fn target() -> [f32; 4] {
let Ok(raw) = std::env::var("WOW_PROBE_STONE") else {
return STONE_AT;
@@ -168,8 +134,7 @@ fn target() -> [f32; 4] {
}
}
/// One leg's verdict line. `got == want` is the whole test; the line says both either way, so a
/// failure reads as a measurement rather than as an assertion name.
/// One leg's verdict line, saying both `want` and `got`.
fn check(label: &str, want: &str, got: &str) -> u32 {
if want == got {
info!("PROBE_STONE: {label:<9} PASS — {got}");
@@ -180,8 +145,7 @@ fn check(label: &str, want: &str, got: &str) -> u32 {
}
}
/// The three live VM readings every leg is written in, asked of the **stock files**: the two
/// bindings `Minimap.xml` calls, and whether its button is actually up.
/// The live VM readings: the two bindings stock `Minimap.xml` calls, and whether its button is up.
fn vm_state(script: &UiScript) -> (bool, Option<String>, bool) {
let queued = script
.eval::<bool>("return IsInMeetingStoneQueue() and true or false")
@@ -215,7 +179,7 @@ fn stone_probe(
return; // not in-world yet
};
let Some(script) = script else {
return; // no UI VM this build — nothing this probe can read
return; // no UI VM in this build
};
let now = time.elapsed_secs_f64();
let level = store.0.unit_level().unwrap_or(0);
@@ -331,8 +295,7 @@ fn stone_probe(
if queued { "" } else { "un" }
),
);
// `.character level 0` is not a level; a template that ships `data[0] = 0` means
// "no floor", which for this leg is simply level 1.
// `data[0] = 0` means no floor, which for this leg is level 1.
let want = probe.tmpl.map_or(1, |t| t.min_level.max(1));
info!("PROBE_STONE: levelling to {want} for the join leg");
let _ = net.0.send(ClientCommand::Chat {
@@ -375,8 +338,7 @@ fn stone_probe(
if text.is_some() { "yes" } else { "no" }
),
);
// Leg 4 — `0x5f6990` over the LIVE queue: this stone is now the one we are queued at,
// so its highlightable slot must refuse it.
// Leg 4: `0x5f6990` over the live queue refuses the stone we are queued at.
probe.darkened = meeting_stone_queued(Some(area), queue.area);
probe.fails += check("DARKENED", "true", &probe.darkened.to_string());
info!("PROBE_STONE: leaving through the live VM's own CancelMeetingStoneRequest()");
+38 -68
View File
@@ -1,16 +1,12 @@
//! The taxi-flight live probe (`WOW_PROBE=taxi`) — decision 0484's end-to-end instrument, inert
//! without the env: once in-world, GM-hop to the Stormwind flight master (Dungar Longdrink),
//! open the taxi menu on the real wire (`CMSG_TAXIQUERYAVAILABLENODES` → `SMSG_SHOWTAXINODES`),
//! activate the short verified hop Stormwind → Sentinel Hill (nodes 2 → 4, TaxiPath 6), and ride
//! the server's flying spline to arrival. Every phase edge prints a `PROBE taxi:` line and the
//! landing prints a SUCCESS/FAILURE verdict with the two machine checks of the 0484 gate:
//! 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 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).
//! The taxi-flight live probe (`WOW_PROBE=taxi`): hops to Stormwind's flight master, opens the
//! menu (`CMSG_TAXIQUERYAVAILABLENODES`, `SMSG_SHOWTAXINODES`), flies Stormwind to Sentinel Hill
//! and logs a `PROBE taxi:` line at each edge. The landing verdict checks the distance to the
//! destination node's DBC position, the flight time against the path length over 32 yd/s, the
//! in-flight anims and the flying pitch and bank; then it holds `W` and logs when movement starts.
//!
//! Non-combat. `.taxicheat on` lets a fresh character fly to an unvisited node; the ~110-copper
//! fare comes from the character's own purse. The switches are `docs/CONTRIBUTING.md`, "Running
//! it unattended".
use benilla_assets::coords::bevy_to_wow;
use benilla_formats::{load_taxi_nodes, load_taxi_path_nodes};
@@ -22,19 +18,16 @@ use crate::player::Player;
use crate::ui_taxi::TaxiState;
use benilla_assets::{LockRecover, WorldAssets};
/// The flight under test: Stormwind (node 2) → Sentinel Hill (node 4), TaxiPath id 6 — the pair
/// byte-verified against the real 5875 tables by the phase-1 catalog tests.
/// The flight under test: Stormwind (node 2) to Sentinel Hill (node 4), TaxiPath 6.
const SRC_NODE: u32 = 2;
const DEST_NODE: u32 = 4;
const TAXI_PATH: u32 = 6;
/// Dungar Longdrink's spawn (vmangos `creature` guid 79658, entry 352, map 0) — the `.go xyz`
/// target; the flight master is then scanned from the streamed world by its npc flag, never by a
/// hardcoded guid.
/// Dungar Longdrink's spawn (vmangos `creature` guid 79658, entry 352, map 0); the flight master
/// is then found in the streamed world by his npc flag.
const FLIGHTMASTER_AT: [f32; 3] = [-8835.8, 490.1, 109.7];
/// `UNIT_NPC_FLAG_FLIGHTMASTER` (bit 3) — the same bit the cursor classifier keys on.
/// `UNIT_NPC_FLAG_FLIGHTMASTER` (bit 3).
const NPC_FLAG_FLIGHTMASTER: u32 = 0x8;
/// vmangos `PLAYER_FLIGHT_SPEED` (yd/s, hardcoded server-side) — the duration prediction's
/// divisor.
/// vmangos `PLAYER_FLIGHT_SPEED` in yd/s (`WaypointMovementGenerator.cpp:390`).
const FLIGHT_SPEED: f32 = 32.0;
pub(crate) struct ProbeTaxiPlugin;
@@ -49,8 +42,7 @@ impl Plugin for ProbeTaxiPlugin {
.add_systems(Update, taxi_probe)
.add_systems(
PreUpdate,
// After the loading cover's input swallow as well as winit's input pass — see
// `probes::act::ProbeKeyPlugin` for why a synthetic press orders past it.
// After winit's input pass and the loading cover's input swallow.
hold_w_post_land
.after(bevy::input::InputSystems)
.after(crate::loading_screen::CoverInput),
@@ -58,17 +50,15 @@ impl Plugin for ProbeTaxiPlugin {
}
}
/// The DBC-derived expectations, loaded once off the patch chain: the destination node's world
/// position (the arrival assert) and the path's total length (the duration prediction).
/// From the DBCs: the destination node's position and the path's total length.
#[derive(Default)]
struct Expectations {
dest_pos: Option<[f32; 3]>,
path_len: Option<f32>,
}
/// The probe's phase machine. `Wait` → (GM hop sent) `Hopped` → (flight master streamed in,
/// query sent) `Queried` → (map opened, activate sent) `Activated` → (self-spline riding)
/// `Flying` → (ride ended) verdict → `Done`.
/// `Wait`, `Hopped` (hop sent), `Queried` (query sent), `Activated` (activate sent), `Flying`
/// (riding the spline), `PostLand`, `Done`.
#[derive(Resource, Default)]
struct TaxiProbe {
expect: Expectations,
@@ -93,21 +83,15 @@ enum Phase {
Flying {
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) (`0x5fd19c` + the 0441 mount pin).
/// Latched once the rider plays Mount (91) and the mount child Fly (135) (`0x5fd19c`).
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
/// Westfall's hills, so a working tilt shows ≳0.1 rad; ~0 means it never rendered.
/// The largest flying pitch magnitude (radians) on the self transform.
max_pitch: f32,
/// The largest |flying BANK| (radians) mid-flight — the 0516 look-ahead lean. The route
/// turns repeatedly, so a working bank shows ≳0.05 rad; ~0 means no lean rendered.
/// The largest flying bank magnitude (radians), the look-ahead lean.
max_bank: f32,
},
/// Post-landing diagnosis (the director's "we float a char height and can't move for ~5 s"):
/// W is held synthetically ([`hold_w_post_land`]) from the moment the verdict prints; each
/// second this logs the pose — height over the ground (a conform-style down-ray), distance
/// from the landing point, ride/mount state — and stamps when movement actually began.
/// After landing, with `W` held ([`hold_w_post_land`]): logs height over ground, distance
/// moved and ride state each second, and when movement began.
PostLand {
landed_at: f64,
landed_pos: Vec3,
@@ -143,7 +127,6 @@ fn load_expectations(mut probe: ResMut<TaxiProbe>, world_assets: Option<Res<Worl
}
}
// One Bevy system's full input set (the crossing-probe shape) + its self query tuple.
#[allow(clippy::type_complexity)]
fn taxi_probe(
time: ProbeClock,
@@ -174,14 +157,8 @@ fn taxi_probe(
Phase::Wait => {
let [x, y, z] = FLIGHTMASTER_AT;
info!("PROBE taxi: hopping to the Stormwind flight master at ({x}, {y}, {z})");
// The fare is seeded offline in `characters.money` (100000 copper; a flight spends
// ~110) — re-seed via `UPDATE characters SET money=100000 WHERE name LIKE 'Probe%'
// AND online=0;` if a NOT_ENOUGH_MONEY reply ever shows up. The seed used to be the
// *only* option: `.modify money` is SEC_BASIC_ADMIN(4) and the probe accounts were
// gmlevel 3 (two runs bounced on NOT_ENOUGH_MONEY before that was traced). Since they
// were actually raised to 6 (0651) this probe could grant its own fare in-band the way
// `probe_bank` now does — left alone because the offline seed works and is one fewer
// command inside the flight window.
// The fare comes from the character's purse; a NOT_ENOUGH_MONEY reply means it is
// empty (`.modify money` refills it).
let _ = net
.0
.send(ClientCommand::SetSelection { guid: self_guid.0 });
@@ -198,8 +175,7 @@ fn taxi_probe(
probe.phase = Phase::Hopped { sent_at: now };
}
Phase::Hopped { sent_at } => {
// Post-teleport settle, then scan the streamed world for a flight-master-flagged
// unit in interaction range — the guid comes from the wire, never hardcoded.
// Settle, then find a flight-master-flagged unit in range.
if now - sent_at < 3.0 {
return;
}
@@ -251,8 +227,8 @@ fn taxi_probe(
});
probe.phase = Phase::Activated { sent_at: now };
} else if now - sent_at > 2.0 && !retried {
// First contact LEARNS, never opens (vmangos SendLearnNewTaxiNode) — the second
// query opens the menu.
// A first visit learns the node and opens nothing (vmangos
// `TaxiHandler.cpp:75`); the second query opens the menu.
info!("PROBE taxi: no menu yet (first-visit learn?) — querying again");
let _ = net
.0
@@ -307,16 +283,14 @@ fn taxi_probe(
} => {
let wow = bevy_to_wow(player.pos);
if player.server_riding() {
// 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).
// Rider Mount (91), mount child Fly (135) (`0x5fd19c`); latched, as the first
// frames lag the mount attach.
let rider = drivers.get(self_entity).ok().map(|d| d.playing().0);
let mount = mount_child
.and_then(|mc| drivers.get(mc.0).ok())
.map(|d| d.playing().0);
let pair_ok = rider == Some(Some(91)) && mount == Some(Some(135));
// The flying attitude (decisions 0501/0516): `sample_splines` composes
// `Ry(f)·Rx(pitch)·Rz(bank)`, so the YXZ euler reads back (yaw, pitch, bank).
// `sample_splines` composes `Ry(f) * Rx(pitch) * Rz(bank)`, so YXZ reads it back.
let (_, pitch, bank) = self_tf.rotation.to_euler(EulerRot::YXZ);
let max_pitch = max_pitch.max(pitch.abs());
let max_bank = max_bank.max(bank.abs());
@@ -350,8 +324,8 @@ fn taxi_probe(
}
return;
}
// The ride ended (server_ride snapped to the endpoint and sent CMSG_MOVE_SPLINE_DONE
// — its own log line). Verdict time.
// The ride ended: `server_ride` snapped to the endpoint and sent
// `CMSG_MOVE_SPLINE_DONE`.
let flew_for = now - started_at;
let dist = probe.expect.dest_pos.map(|p| {
let (dx, dy, dz) = (wow[0] - p[0], wow[1] - p[1], wow[2] - p[2]);
@@ -359,13 +333,10 @@ fn taxi_probe(
});
let predicted = probe.expect.path_len.map(|l| l / FLIGHT_SPEED);
let dist_ok = dist.is_some_and(|d| d < 20.0);
// The server's duration also stretches with the mount-up delays and our
// spline-arrival timing; a ±15% band catches a wrong curve/speed without flaking.
// A 15% band: the server's duration also holds the mount-up delays.
let time_ok =
predicted.is_some_and(|p| (flew_for - f64::from(p)).abs() < f64::from(p) * 0.15);
// The route climbs Westfall's hills AND turns repeatedly: a working flying attitude
// shows well over 0.05 rad (~3°) on each axis somewhere along it; ~0 means that
// axis never rendered (decisions 0501/0516).
// The route climbs and turns, so each axis passes 0.05 rad somewhere along it.
let pitch_ok = max_pitch > 0.05;
let bank_ok = max_bank > 0.05;
let verdict = if dist_ok && time_ok && gait_ok && pitch_ok && bank_ok {
@@ -415,7 +386,7 @@ fn taxi_probe(
if since - last_log >= 1.0 {
last_log = since;
let wow = bevy_to_wow(player.pos);
// Conform-style ground probe: from 2 yd above the feet, 50 yd down.
// Ground probe: from 2 yd above the feet, 50 yd down.
let origin = player.pos + Vec3::Y * 2.0;
let ground = spatial
.cast_ray_predicate(
@@ -460,9 +431,8 @@ fn taxi_probe(
}
}
/// Hold W from the landing verdict on (PreUpdate, after winit's input processing so the synthetic
/// press is visible to the controller the same frame — the [`super::probes::ProbeKeyPlugin`]
/// pattern): the post-land phase measures when the avatar actually starts moving.
/// Holds `W` from the landing verdict on, in `PreUpdate` after winit's input pass so the
/// controller sees it the same frame.
fn hold_w_post_land(probe: Res<TaxiProbe>, mut keys: ResMut<ButtonInput<KeyCode>>) {
if matches!(probe.phase, Phase::PostLand { .. }) {
keys.press(KeyCode::KeyW);
@@ -1,42 +1,23 @@
//! The **vendor-swap** live probe (`WOW_PROBE_VENDOR_SWAP=1`) — decision 2022's end-to-end
//! instrument: does a second vendor, opened while the first vendor's window is still up, swap the
//! WHOLE window — the stock `MerchantFrame_UpdateMerchantInfo`'s `UnitName("NPC")` title and its
//! `SetPortraitTexture(MerchantFramePortrait, "NPC")` face — on the very dispatch that raises
//! `MERCHANT_SHOW`; and does closing the window clear the `"npc"` token?
//!
//! The director's report (2026-09-05): the Goldshire inn's vendor window titled "Innkeeper
//! Farley" over Brog Hamfist's stock, with an empty portrait ring; then "clicking between vendors
//! while the window is open doesn't swap name and some other things maybe too". Both faces of
//! one seam — the decision has the mechanism. This probe stands in that inn, between its two
//! pure vendors (**Brog Hamfist** 151 and **Barkeep Dobbins** 465, 5.45 yd apart — live-DB
//! verified), so both are inside the service reach at once and the second open lands on top of
//! the first window exactly as the click does.
//! The vendor-swap live probe (`WOW_PROBE_VENDOR_SWAP=1`): does a second vendor, opened over the
//! first's window, swap the whole window (stock `MerchantFrame.lua:67-68`, the `UnitName("NPC")`
//! title and the `"NPC"` portrait) on the dispatch that raises `MERCHANT_SHOW`, and does closing
//! the window clear the `"npc"` token? It stands in the Goldshire inn between Brog Hamfist (151)
//! and Barkeep Dobbins (465), 5.45 yd apart, both within service reach.
//!
//! Legs, one `PROBE_VENDOR_SWAP: <leg> PASS/FAIL/SKIP` line each:
//! 1. **open** — `CMSG_LIST_INVENTORY` to Brog; at the `MERCHANT_SHOW` dispatch, `UnitName("npc")`
//! and the title both read Brog's name.
//! 2. **portrait** — the round region is being sampled under the canonical `"npc"` key
//! (`portrait::BoothPanes`) and the booth publishes a live bake for it (`PortraitImages`).
//! 3. **swap** — with Brog's window still open, `CMSG_LIST_INVENTORY` to Dobbins; at that
//! `MERCHANT_SHOW`, both readings are Dobbins' name.
//! 4. **title** — a second later, the title the window is actually left showing is his too.
//! 5. **clear** — `HideUIPanel(MerchantFrame)` (the stock OnHide → `CloseMerchant`), and once the
//! session is gone `UnitExists("npc")` is nil.
//! 1. open: `CMSG_LIST_INVENTORY` to Brog; at `MERCHANT_SHOW`, `UnitName("npc")` and the title
//! both read his name.
//! 2. portrait: the round region is sampled under the `"npc"` key (`portrait::BoothPanes`) and the
//! booth publishes a live bake for it (`PortraitImages`).
//! 3. swap: with Brog's window open, `CMSG_LIST_INVENTORY` to Dobbins; at that `MERCHANT_SHOW`,
//! both read Dobbins' name.
//! 4. title: a second later the window still shows his name.
//! 5. clear: `HideUIPanel(MerchantFrame)` (stock OnHide calls `CloseMerchant`); once the session
//! is gone `UnitExists("npc")` is nil.
//!
//! The event readings are taken **inside the event** by a Lua hook frame registered after the
//! stock window's, so a later `MERCHANT_UPDATE` repaint cannot mask what the open frame saw —
//! which is how the bug hid: a vendor whose items were already cached got no repaint, and one
//! whose items were still streaming got one a moment later that quietly corrected the title.
//!
//! Non-combat, nothing bought, nothing sold; the window is closed the way its own close button
//! closes it (no packet).
//!
//! ## The run recipe
//! ```text
//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone WOW_UNATTENDED=1 WOW_NOSOUND=1 \
//! WOW_PROBE_VENDOR_SWAP=1 cargo run -q -p benilla
//! ```
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)).
//! The event readings are taken inside the event by a hook frame registered after the stock one,
//! so a later `MERCHANT_UPDATE` repaint cannot mask what the open left. Non-combat, nothing bought
//! or sold. The switches are `docs/CONTRIBUTING.md`, "Running it unattended".
use bevy::prelude::*;
@@ -50,32 +31,26 @@ use crate::player::Player;
use crate::portrait::{BoothPanes, PortraitImages, PortraitSource};
use crate::ui_merchant::MerchantOpen;
/// Brog Hamfist `<General Supplies>` — `creature_template.entry = 151`, `npc_flags = 4`.
/// Brog Hamfist `<General Supplies>`: `creature_template.entry = 151`, `npc_flags = 4`.
const BROG_ENTRY: u32 = 151;
/// Barkeep Dobbins — `entry = 465`, `npc_flags = 4`.
/// Barkeep Dobbins: `entry = 465`, `npc_flags = 4`.
const DOBBINS_ENTRY: u32 = 465;
/// Midway between the two spawns (`creature.position_*`: Brog −9465.29, 9.63; Dobbins −9459.98,
/// 8.41; both z 57.15), so each is ~2.8 yd away — inside the 5.5556 yd service reach.
/// Midway between the two spawns (Brog -9465.29, 9.63; Dobbins -9459.98, 8.41; both z 57.15),
/// ~2.8 yd from each, inside the 5.5556 yd service reach.
const STAND_AT: [f32; 3] = [-9462.6, 9.0, 57.15];
const MAP: u32 = 0;
/// Let the hop land and the inn stream before the first scan.
const SETTLE_SECS: f64 = 3.0;
/// How long to wait for both vendors (and their names) before calling the hop environmental.
/// How long to wait for both vendors and their names before a SKIP.
const SCAN_TIMEOUT_SECS: f64 = 20.0;
/// How long a leg waits for its window / its event after the opcode goes out.
const WINDOW_TIMEOUT_SECS: f64 = 15.0;
/// How long the round portrait may take to be sampled and baked once the window is open.
const PORTRAIT_TIMEOUT_SECS: f64 = 10.0;
/// How long after the swap to read the title the window is actually left showing.
/// How long after the swap to read the title the window is left showing.
const SETTLED_SECS: f64 = 1.0;
/// The service reach the click cannot act past (`crate::target::SERVICE_RANGE_SQ`).
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
/// The hook frame: one line per merchant event, read INSIDE the dispatch. Registered after the
/// stock `MerchantFrame` (the probe installs it in-world, long after the FrameXML load), so the
/// stock `OnEvent` has already repainted by the time this runs and `MerchantNameText` shows what
/// the open frame left there.
/// The hook frame: one line per merchant event, read inside the dispatch. Registered after the
/// stock `MerchantFrame`, so `MerchantNameText` shows what the stock `OnEvent` left there.
const HOOK: &str = r#"
if not ProbeVendorSwapHooked then
ProbeVendorSwapHooked = true
@@ -217,8 +192,7 @@ fn vendor_swap_probe(
.map(|(guid, ..)| guid.0)
};
let (brog, dobbins) = (find(BROG_ENTRY), find(DOBBINS_ENTRY));
// Both names must be cached before the legs read them back out of the VM — the
// ask-once resolve is what the unit feed itself would send on the open frame.
// Both names must be cached before the legs read them back out of the VM.
let named = |g: Option<u64>| g.is_some_and(|g| names.resolve(g, &net).is_some());
let (brog_named, dobbins_named) = (named(brog), named(dobbins));
if brog_named && dobbins_named {
@@ -379,11 +353,9 @@ fn vendor_swap_probe(
probe.phase = Phase::Done;
}
None => {}
// One more poll after the clear: the unit feed pushes the token's absence on the
// frame after the session clears.
// The unit feed drops the token the frame after the session clears.
Some(at) if now - at >= 0.25 => {
// `and 1 or 0`: the binding answers a Lua boolean where the reference
// answers `1`/nil (its own noted question), and a `false ~= nil` is true.
// `and 1 or 0` turns `UnitExists`'s `1` or nil into a number.
let exists: i64 = script
.eval(r#"return UnitExists("npc") and 1 or 0"#)
.unwrap_or(1);
@@ -411,9 +383,7 @@ fn vendor_swap_probe(
"PROBE_VENDOR_SWAP: DONE pass={} fail={} skip={}",
probe.passes, probe.fails, probe.skips
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
// `AppExit` plus a hard backstop, so a teardown hang cannot keep the account held.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
+81 -266
View File
@@ -1,22 +1,13 @@
//! The **actuation** probes: the three headless channels into a live session — scripted chat
//! sends ([`ProbeChatPlugin`]), synthetic key taps ([`ProbeKeyPlugin`]), and a Lua chunk in the
//! live UI VM ([`ProbeLuaPlugin`]). Each is env-gated, waits for the session to be in-world, and
//! schedules on [`ProbeClock`]; together they are "do what the director would do, unattended".
//! The actuation probes: chat lines, key taps, a Lua chunk, a pointer drag and a hover sweep
//! driven into a live session. Each is env-gated, waits for the world and runs on [`ProbeClock`].
use bevy::prelude::*;
use super::ProbeClock;
/// The PROBE CHAT one-shot (`WOW_PROBE_CHAT="<line>[;<line>…]"`, delay via `WOW_PROBE_CHAT_AT`
/// seconds, default 8): send each `;`-separated line as Say once we are in-world — the "park the
/// probe character anywhere" instrument. The probe account (gmlevel 6) makes `.go xyz …`, `.gm on`,
/// `.additem` etc. work headlessly, which a direct `characters` DB edit does NOT (the live world
/// server's logout save overwrites it, and the row is only re-read at login). Pair with
/// [`LiveShotPlugin`] at a later `WOW_LIVE_SHOT_AT` so the destination has streamed in.
/// `WOW_PROBE_CHAT_EVERY=<secs>` spaces the lines apart instead of sending them in one burst —
/// the "do X, wait, then do Y" probe (a mount-then-dismount transition, a buff-then-cancel):
/// two field flips inside one drain merge to a no-op, so time-separated sends are what actually
/// exercise a transition (decision 0441's teardown verification).
/// `WOW_PROBE_CHAT="<line>[;<line>…]"`: types each line into the chat box once in-world, from
/// `WOW_PROBE_CHAT_AT` seconds (default 8), `WOW_PROBE_CHAT_EVERY` seconds apart (default one
/// burst). Spacing matters: two field flips inside one drain merge to a no-op.
pub(crate) struct ProbeChatPlugin;
impl Plugin for ProbeChatPlugin {
@@ -40,8 +31,7 @@ impl Plugin for ProbeChatPlugin {
}
}
/// [`ProbeChatPlugin`] state: the `;`-separated lines, the first-fire time, the per-line spacing
/// (`0` = one burst), and how many lines have gone out.
/// [`ProbeChatPlugin`] state; `every` of 0 sends every line in one burst.
#[derive(Resource)]
struct ProbeChat {
lines: String,
@@ -50,14 +40,8 @@ struct ProbeChat {
sent: usize,
}
/// Submit the probe lines once the delay has elapsed AND the session is in-world (the self player
/// exists) — a `.go` sent before world-enter would be dropped server-side.
///
/// Lines go in through the **chat EditBox seam**, not straight to the wire: a probe line is "what
/// the director would type", so a client-side slash command (`/duel`, `/reaction`) is parsed by
/// the same drain that serves the real chat box, while plain text and `.gm`/`.go` server commands
/// still leave as Say exactly as before. Sending them as Say instead — the original shape — meant
/// every client-side command silently went out as public chat and did nothing (decision 0637).
/// Submits due lines once a self player exists (the server drops a `.go` sent before world entry),
/// through the chat box so client-side slash commands parse as typed ones do.
fn fire_probe_chat(
mut probe: ResMut<ProbeChat>,
time: ProbeClock,
@@ -68,13 +52,12 @@ fn fire_probe_chat(
return;
}
if self_player.is_empty() {
return; // not in-world yet — keep waiting past the delay
return; // not in-world yet
}
let Some(mut script) = script else {
return;
};
// With no spacing every line goes in the first eligible frame (the original burst); with
// `every`, line N waits until `at + N·every` — the "do X, wait, then do Y" cadence.
// Line N is due at `at + N·every`.
loop {
let Some(line) = probe
.lines
@@ -95,23 +78,10 @@ fn fire_probe_chat(
}
}
/// The PROBE KEY one-shots (`WOW_PROBE_KEY="<key>@<secs>[:<hold>][;…]"`): synthesize a key press
/// at each given time once in-world, released `<hold>` seconds later ([`PROBE_KEY_TAP_SECS`] when
/// omitted — the tap this instrument shipped with). The "press space headlessly" instrument for
/// input-gated behavior (the mounted flourish, a jump, the X/Z toggles), which neither a chat
/// command nor a Lua chunk can reach (1.12 has no jump Lua API; the gate lives in the
/// controller's key read).
///
/// The optional hold is what makes *sustained* locomotion reachable headlessly: a 0.25 s W tap
/// travels ~1.2 yd, far too little to cross a liquid surface's own slope, so a swim defect that
/// only appears while moving over water could not be reproduced without asking the director to
/// drive (decision 0644 — the gap `WOW_PROBE_LOOK` closed for mouse-turns, on the key side).
///
/// Runs in `PreUpdate`
/// after winit's input processing ([`bevy::input::InputSystems`]) so the synthetic
/// `just_pressed` is visible to every `Update` reader that same frame — a press from inside
/// `Update` would be cleared at the next frame's input pass before an earlier-ordered
/// controller ever saw it.
/// `WOW_PROBE_KEY="<key>@<secs>[:<hold>][;…]"`: presses each key at its time once in-world and
/// releases it `<hold>` seconds later ([`PROBE_KEY_TAP_SECS`] when omitted). Reaches input no chat
/// line or Lua chunk can (1.12 has no jump Lua API). Runs in `PreUpdate` after
/// [`bevy::input::InputSystems`], so every `Update` reader sees the `just_pressed` that frame.
pub(crate) struct ProbeKeyPlugin;
impl Plugin for ProbeKeyPlugin {
@@ -147,19 +117,16 @@ impl Plugin for ProbeKeyPlugin {
bevy::app::PreUpdate,
fire_probe_key
.after(bevy::input::InputSystems)
// …and after the loading cover's input swallow, which runs in the same window
// and empties every button plane: an instrument driving the client is the
// operator, not the player, so its press has to land on the far side of it.
// After the loading cover empties every button plane, so the press survives.
.after(crate::loading_screen::CoverInput),
);
}
}
/// How long a probe press stays held when the spec gives no `:<hold>`. Long enough that a
/// `pressed`-reader (a held-key gate) sees it across several frames; short enough to stay a tap.
/// Default hold: several frames for a held-key reader, still a tap.
const PROBE_KEY_TAP_SECS: f32 = 0.25;
/// The key names [`ProbeKeyPlugin`] accepts — the controller-read set; extend as probes need.
/// The key names [`ProbeKeyPlugin`] accepts.
fn probe_key_by_name(name: &str) -> Option<KeyCode> {
Some(match name {
"Space" => KeyCode::Space,
@@ -172,86 +139,52 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
"X" => KeyCode::KeyX,
"Z" => KeyCode::KeyZ,
"Tab" => KeyCode::Tab,
// TOGGLEAUTORUN's 1.12 default. The moving-axis protocol (task: grade streaming while
// running) engages autorun with one tap and steers with `WOW_PROBE_LOOK` — the whole
// scripted drive out of two instruments that already existed.
// TOGGLEAUTORUN's 1.12 default; with `WOW_PROBE_LOOK` it scripts a drive.
"NumLock" => KeyCode::NumLock,
// The nameplate toggles (`vplates`): bare `V` is enemy plates, `Shift`-held `V` is
// friendly. A probe that wants plates over a town's *friendly* NPCs — the only way to
// measure plate behaviour without standing in aggro range of something — presses this.
// The nameplate toggle: bare `V` shows enemy plates, `Shift`+`V` friendly ones.
"V" => KeyCode::KeyV,
// The detached free-fly toggle (`player.rs`) and its Ctrl speed boost (`camera.rs`, ×5).
// Added because the harness could not reach the leg that broke decision 0793: the director's
// first real run was a boosted free-fly, whose camera crosses the art radius in ~5 s, and
// reproducing it needed a held `Ctrl` + `W` behind an `F`.
//
// The toggle is the dev chord + `F` since decision 1043 (it was a bare `F`), so a probe that
// wants free-fly holds `Ctrl` and `Shift` across the `F` tap — hence `Shift` here. `Ctrl`
// stays held afterwards for the ×5 boost, which is unchanged.
// Free-fly is the dev chord (`Ctrl`+`Shift`) + `F` (`player.rs`); a held `Ctrl` is its ×5
// speed boost (`camera.rs`).
"F" => KeyCode::KeyF,
"Ctrl" => KeyCode::ControlLeft,
"Shift" => KeyCode::ShiftLeft,
// The cost pill's toggle, dev chord + `P` (`perf::hud::toggle_hud`). Reachable no other
// way — it is a host chord, not a binding or a Lua verb — and since the pill starts hidden
// (2099) a probe that wants to see it in a shot has to press it.
// The cost pill's toggle, dev chord + `P` (`perf::hud::toggle_hud`); it starts hidden.
"P" => KeyCode::KeyP,
// The text-editing keys. Added for decision 1077's hyperlink-atomicity law, whose whole
// observable — "one BACKSPACE removes a whole item link" — is a keypress no chat command
// and no Lua chunk can reach (`EditBox` has no Lua deletion API; the law lives behind the
// host's chord table). Without these a caret/deletion defect could only be reproduced by
// asking the director to type, which is the asymmetry probes exist to remove.
// Text editing: `EditBox` has no Lua deletion API, so caret and deletion need real keys.
"Backspace" => KeyCode::Backspace,
"Delete" => KeyCode::Delete,
"Left" => KeyCode::ArrowLeft,
"Right" => KeyCode::ArrowRight,
"Home" => KeyCode::Home,
"End" => KeyCode::End,
// Enter at the character-select screen IS "enter world" (`char_select::input`), which makes
// the **second** login of a run reachable headlessly: `/logout` then `Enter` reproduces a
// character switch in one process. Session-scoped state that survives that boundary is a
// whole bug class, and until this key existed no probe could reach it — 1284's stale channel
// membership had to be found by the director, twice. `WOW_CHAR` is a deliberate one-shot
// (`run_mode`), so synthesizing the keypress is the only way back into the world in-process.
// Enter at character select enters the world (`char_select::input`): `/logout` then
// `Enter` is a relog in one process, since `WOW_CHAR` applies once (`run_mode`).
"Enter" => KeyCode::Enter,
// Print screen (decision 1487). The whole player path — key → SCREENSHOT binding →
// `TakeScreenshot()` → `Screenshot()` → the writer → SCREENSHOT_SUCCEEDED — is reachable
// no other way: a Lua chunk can call the verb but skips the binding, and B261's contract
// ("the message is not in the file") is a property of the KEY press, since the whole
// reason `TakeScreenshot` hides the status line is a second press landing on a live one.
// On macOS this arrives as F13 (`bindings::chord`'s fold) and the token is the same.
// The SCREENSHOT binding's key; a Lua `TakeScreenshot()` skips the binding. On macOS it
// arrives as F13 (`bindings::chord`'s fold) under the same token.
"PrintScreen" => KeyCode::PrintScreen,
_ => return None,
})
}
/// [`ProbeKeyPlugin`] state: one entry per scheduled tap, plus the once-armed latch.
#[derive(Resource)]
struct ProbeKeys {
taps: Vec<ProbeKeyTap>,
/// **A self player has existed at some point this run.** The gate used to be "a self player
/// exists *right now*", which is what keeps a tap from firing into the loading screen — but it
/// also made every glue screen unreachable, and the second half of a `/logout` + `Enter`
/// character-switch probe happens at exactly one of those (1284). Latching it keeps the "not
/// before the world" intent and drops the "only while in it" accident.
/// A self player has existed this run: latched, so taps reach glue screens after a `/logout`.
armed: bool,
}
struct ProbeKeyTap {
key: KeyCode,
at: f32,
/// Seconds the key stays down — the spec's `:<hold>`, else [`PROBE_KEY_TAP_SECS`].
/// Seconds held: the spec's `:<hold>`, else [`PROBE_KEY_TAP_SECS`].
hold: f32,
pressed: bool,
released: bool,
}
/// Press each due tap (in-world gated, like the chat probe) and release it after its hold window.
///
/// Both input currencies, deliberately (0997): `ButtonInput` for every held-state reader (and
/// the binding dispatch's stuck-latch sweep, which treats "latched but not pressed" as a missed
/// release), plus the raw [`KeyboardInput`] message the binding dispatch's press/release edges
/// actually consume — a state-only synthetic press was invisible to the chord latcher, which
/// would have silently killed this instrument the day the dispatch landed.
/// Presses and releases due taps through both `ButtonInput` (held-state readers) and a raw
/// [`KeyboardInput`] message (the binding dispatch's press/release edges); both are needed.
fn fire_probe_key(
mut probe: ResMut<ProbeKeys>,
time: ProbeClock,
@@ -292,12 +225,9 @@ fn fire_probe_key(
tap.released = true;
}
}
// Publish what we are holding, so the macOS stuck-modifier reconciler
// ([`benilla_world::modkeys`]) doesn't undo it: it polls the *hardware* flags, which read
// "up" for a key no hand is on, so before this every synthesized chord — `SHIFT-V`,
// `Ctrl`+`Shift`+`F` — was released the frame after it was pressed, and the release was
// logged as a stuck-key correction. Rewritten only when the held set changes (a plain
// `ResMut` write every frame would mark the resource changed forever).
// Publish the held keys so the macOS stuck-modifier reconciler ([`benilla_world::modkeys`]),
// which polls hardware flags, does not release them. Written only on change, to keep the
// resource's change tick meaningful.
let held: Vec<KeyCode> = probe
.taps
.iter()
@@ -309,26 +239,10 @@ fn fire_probe_key(
}
}
/// The PROBE LUA driver (`WOW_PROBE_LUA="<chunk>"`, first fire at `WOW_PROBE_LUA_AT` seconds,
/// default 10; re-armed `WOW_PROBE_LUA_AGAIN` seconds into every LATER world entry, default 4):
/// run one Lua chunk in the live UI VM — the "press the button headlessly" instrument. The chunk
/// drives the REAL FrameXML API surface (`CastSpell`, `UseAction`, `TargetUnit`, …), so whatever
/// it triggers takes the exact app path a click takes — a headless wire probe can measure the
/// server, but only the live VM exercises the button feed and the widget clock.
///
/// **The chunk fires once per WORLD ENTRY, not once per process** (decision 2116's named gap).
/// The UI VM is destroyed and rebuilt at every logout/login (1290/1291), taking `_G` and the
/// `ProbeLog` channel below with it — so while this was a process-wide one-shot, a probe could
/// not read the same value before and after a relog *at all*. 2116 needed exactly that (the
/// cooldown triple across a `/logout`) and had to fall back to `WOW_PROBE_CHAT` sending
/// `/script … error("…")` on a schedule and reading the warn line the input drain logs — a trick
/// standing in for an instrument, and a morning instead of an hour.
///
/// **World entry, and deliberately not "VM rebuild"**, which is the wider edge and the wrong one:
/// `ReloadUI()` rebuilds the VM without leaving the world, and `WOW_PROBE_LUA="ReloadUI()"` is a
/// probe we actually run (decision 2028). Re-arming on the VM would make that chunk reload the
/// interface forever. [`ProbeLog`](install_probe_log) *does* follow the VM, because it is a
/// channel rather than an action.
/// `WOW_PROBE_LUA="<chunk>"`: runs the chunk in the live UI VM once per world entry, first at
/// `WOW_PROBE_LUA_AT` seconds (default 10), then `WOW_PROBE_LUA_AGAIN` seconds (default 4) into
/// each later entry. Re-armed on world entry, not VM rebuild, or `ReloadUI()` would loop; the
/// [`ProbeLog`](install_probe_log) channel does follow the VM.
pub(crate) struct ProbeLuaPlugin;
impl Plugin for ProbeLuaPlugin {
@@ -352,41 +266,27 @@ impl Plugin for ProbeLuaPlugin {
}
}
/// [`ProbeLuaPlugin`] state: the chunk, the two schedules, and the per-entry bookkeeping that
/// replaced the process-wide once-latch.
#[derive(Resource, Default)]
struct ProbeLua {
chunk: String,
/// `WOW_PROBE_LUA_AT` — the FIRST fire, on the run's own [`ProbeClock`].
at: f32,
/// `WOW_PROBE_LUA_AGAIN` — the settle every LATER entry gets, on its own clock.
again: f32,
/// World entries that have become able to take a chunk — counted on the rising edge.
/// Ready world entries, counted on the rising edge.
entries: u32,
/// Entries already fired into; `fired < entries` is "this entry is still owed its chunk".
/// Entries already fired into; `fired < entries` means one is owed.
fired: u32,
/// When the current entry became ready — the re-arm's zero.
ready_at: f32,
/// Last frame's readiness, so the count above is an edge and not a per-frame increment.
was_ready: bool,
/// The VM session `ProbeLog` was last installed into. `0` is "none" —
/// [`benilla_ui::script::UiScript::session`] hands out from 1, so a fresh probe matches no VM
/// and the first ready frame installs.
/// The VM session `ProbeLog` is installed in; `0` matches none, since
/// [`benilla_ui::script::UiScript::session`] counts from 1.
logged_vm: u64,
}
/// **Is this entry's chunk due?** — the whole schedule, as one decision a test can hold.
///
/// The first fire keeps `WOW_PROBE_LUA_AT`'s run-relative meaning ([`ProbeClock`], whose zero
/// trails process start by the boot), and that is not inertia: every recorded probe line in
/// `decisions/` is tuned against it (`WOW_PROBE_LUA_AT=34` in `scripts/summon-live.sh`, and a
/// dozen records besides), and re-reading it as "34 s after the loading screen" would quietly
/// stop reproducing all of them. A later entry cannot use that
/// clock at all — "34 s into the run" is long past by the time a relog lands — so it runs on
/// its own settle instead, measured from the moment that entry could take a chunk.
/// Whether this entry's chunk is due. The first fire is run-relative (`WOW_PROBE_LUA_AT`, which
/// `scripts/summon-live.sh` relies on); a later entry settles from when it became ready.
fn probe_lua_due(probe: &ProbeLua, now: f32) -> bool {
if probe.fired >= probe.entries {
return false; // this entry has had its chunk (or there is no entry yet)
return false; // fired already, or no entry yet
}
if probe.fired == 0 {
now >= probe.at
@@ -395,15 +295,8 @@ fn probe_lua_due(probe: &ProbeLua, now: f32) -> bool {
}
}
/// Install `ProbeLog(text)` — the chunk's data channel OUT of the VM, as greppable `probe-log:`
/// lines; before it existed a probe could only report through screenshots or by erroring.
///
/// **Into whichever VM is live, and re-installed whenever that changes** (decision 2116). A
/// global installed once is gone at the next logout/login *and* at the next `ReloadUI()`, both of
/// which build a new Lua state (1290/1291) — so a chunk that logged happily on its first run died
/// on a nil call afterwards, which is precisely the shape of failure that reads as "the probe did
/// nothing". Installed only while a probe chunk is configured; never part of the shipping API
/// surface.
/// Installs `ProbeLog(text)`, which logs `probe-log:` lines, into the live VM. Relog and
/// `ReloadUI()` build a new Lua state, so it is reinstalled per VM; only while a chunk is set.
fn install_probe_log(script: &benilla_ui::script::UiScript) {
match script.lua().create_function(|_, text: String| {
info!("probe-log: {text}");
@@ -418,7 +311,7 @@ fn install_probe_log(script: &benilla_ui::script::UiScript) {
}
}
/// Run the probe chunk once per world entry, on [`probe_lua_due`]'s schedule.
/// Runs the chunk once per world entry, on [`probe_lua_due`]'s schedule.
fn fire_probe_lua(
mut probe: ResMut<ProbeLua>,
time: ProbeClock,
@@ -431,24 +324,14 @@ fn fire_probe_lua(
return;
}
let Some(script) = script else {
// No VM in the world at all — the deferral window between the world-entry edge and the
// entry load, where the boot VM is parked out of every feed's reach
// (`ui_script::ParkedBootVm`). Not an entry yet.
// Between the world-entry edge and the entry load the boot VM is parked
// (`ui_script::ParkedBootVm`): not an entry yet.
probe.was_ready = false;
return;
};
// **Ready = this entry can take a chunk**, which is three more facts on top of the VM the
// block above just took. A chunk driving `CastSpell` against the *boot* VM — strings, emote
// tokens and fonts, not one frame — dies on a nil, and "attempt to call a nil value" out of a
// probe reads as a benilla bug rather than as a mis-scheduled probe.
//
// - **`InWorld`** — the entry edge has been taken, which is what ARMS the deferral window
// below. Without it the window has a hole on its leading side: the entered-world message
// and our first object update can arrive in the same drain, a frame ahead of the state
// transition that parks the VM, so a chunk already past `at` would run there.
// - **`PendingEntryUiLoad` gone** — the same window named from the other side: the in-game
// UI is built, not merely owed.
// - **a self player** — `UnitName("player")` is in half the chunks anyone writes.
// Ready means the in-game VM, not the boot one: `InWorld` (the entered-world message and the
// first object update can share a drain a frame before the VM is parked), the entry UI load
// done, and a self player for `UnitName("player")`.
let in_world = *state.get() == crate::char_select::ClientState::InWorld;
let ready = in_world && entry_ui_pending.is_none() && !self_player.is_empty();
let now = time.elapsed_secs();
@@ -478,49 +361,10 @@ fn fire_probe_lua(
}
}
/// The PROBE DRAG driver (`WOW_PROBE_DRAG="A>B[;C>D…]"`, delay via `WOW_PROBE_DRAG_AT` seconds
/// (default 14), one gesture step per `WOW_PROBE_DRAG_STEP` seconds (default 0.1)): drag one named
/// frame onto another **through the real pointer path**, headlessly, in a live session.
///
/// **Why this exists as an instrument rather than a unit test.** The UI's own harness can already
/// press, move and release ([`UiScript::mouse_button`]/`mouse_move`), and a drag test written
/// there passes while the live client's drag is broken — because the harness supplies the whole
/// world: no per-frame app feed between the press and the release, no real frames elapsing, no
/// resolve pass, no `feed_party` re-push, no OS cursor. That gap cost a session (B310: the raid
/// grid's second drag). This closes it by driving the SAME gesture in a real client: the press,
/// the threshold-crossing move, the path, and the release each land on their own frame, with
/// every app system running in between exactly as it does for a hand on the mouse.
///
/// It names FRAMES, not coordinates, and reads their centres out of the live layout, so a probe
/// line survives a window moving. `WOW_PROBE_DRAG_LUA="<chunk>"` runs a chunk after each gesture —
/// the report channel (`ProbeLog` from [`ProbeLuaPlugin`] is installed by that probe; this one
/// prints the chunk's own returned string).
/// The HOVER SWEEP (`WOW_PROBE_HOVER="Frame1;Frame2;…"`, `WOW_PROBE_HOVER_AT` seconds, default 14,
/// `WOW_PROBE_HOVER_STEP` seconds per frame-to-frame step, default 0.25, looping until exit) —
/// the cursor crosses each named frame's centre through the REAL pointer path, pressing nothing.
///
/// **A leg that does not ALTERNATE cannot be read.** `WOW_PROBE_HOVER_DUTY=<on>:<off>` sweeps for
/// `on` seconds, parks the pointer over nothing for `off`, and repeats. Compare-the-two-halves
/// designs that put all the parked frames at the start and all the swept frames after read the
/// run's own drift as the effect: three legs of the same gesture, same binary, gave within-run
/// `cpu_ms` deltas of +2.03, +1.02 and −0.61 ms while their per-phase µs columns agreed to 3%
/// (decision 1634). Alternating pools both regimes across the same minutes, so drift cancels
/// instead of landing on one side.
///
/// **`WOW_PROBE_HOVER_STEP` is the dial that decides what is being measured, and the default is
/// not the director's gesture.** A hand spamming hovers moves the mouse *every frame*; at the
/// 0.25 s default only 4 frames in 60 carry a pointer move, which divides whatever a move costs by
/// fifteen and reads as "hovering is free" (it did, three times, before this note existed). Set it
/// to ~0.016 to sweep at frame rate. `WOW_PROBE_HOVER_JITTER=<px>` then splits the two halves the
/// sweep otherwise fuses: with one name and a jitter, the pointer moves every frame while the
/// hovered frame and its tooltip stay put.
///
/// Built because a hover-cost pin was taken with a Lua driver that called `GameTooltip:SetOwner`
/// directly, and that driver is not the gesture: it never moves `model.mouseover`, so it skips the
/// hit test, `OnEnter`/`OnLeave`, the button's state textures and highlight, and whatever the
/// shipped UI hangs off those. The reported symptom was "hovering costs 2 ms" and the instrument
/// could not hover. This is the missing half of `WOW_HOVER_LOG`: that one records what a hand on
/// the mouse does, and this one supplies the hand.
/// `WOW_PROBE_HOVER="Frame1;Frame2;…"`: moves the pointer across each named frame's centre
/// through the real pointer path, pressing nothing, from `WOW_PROBE_HOVER_AT` (default 14 s),
/// one step per `WOW_PROBE_HOVER_STEP` (default 0.25 s; ~0.016 moves every frame), until exit.
/// `WOW_PROBE_HOVER_DUTY=<on>:<off>` alternates sweeping and parking so run drift cancels.
pub(crate) struct ProbeHoverPlugin;
impl Plugin for ProbeHoverPlugin {
@@ -571,26 +415,20 @@ struct ProbeHover {
names: Vec<String>,
at: f32,
step: f32,
/// `WOW_PROBE_HOVER_JITTER=<px>` — nudge each step off the frame's centre by up to this many
/// pixels, cycling through a small square. Zero (the default) parks the cursor exactly on the
/// centre, so a ONE-NAME sweep issues the same coordinates forever and the pointer never moves.
/// `WOW_PROBE_HOVER_JITTER=<px>`: offset per step; at 0 a one-name sweep never moves.
jitter: f32,
/// `WOW_PROBE_HOVER_DUTY=<on>:<off>` — alternate `on` seconds of sweeping with `off` seconds
/// parked off every frame, forever. See the plugin doc for why a leg that does not alternate
/// cannot be read at this resolution.
/// `WOW_PROBE_HOVER_DUTY=<on>:<off>`: seconds sweeping, then seconds parked.
duty: Option<(f32, f32)>,
i: usize,
next: f32,
announced: bool,
/// Which half of the duty cycle we are in (meaningless when `duty` is `None`).
parked: bool,
}
/// Where the pointer goes during a duty cycle's parked half: the top-left corner, over nothing.
/// The parked pointer: the top-left corner, over no frame.
const HOVER_PARK_AT: (f32, f32) = (2.0, 2.0);
/// Move the cursor onto the next named frame's centre. Loops forever, so a run of any length is a
/// steady sweep — the population the recorder splits on.
/// Moves the pointer onto the next named frame's centre, looping forever.
fn fire_probe_hover(
mut probe: ResMut<ProbeHover>,
mut synthetic: ResMut<crate::ui_script::SyntheticPointer>,
@@ -607,7 +445,7 @@ fn fire_probe_hover(
}
let Some(mut script) = script else { return };
synthetic.0 = true;
// The duty cycle, if armed: which half of the current period is this, and did it just flip?
// Which half of the duty period this is, and whether it just flipped.
if let Some((on, off)) = probe.duty {
let period = (on + off).max(1e-3);
let parked = (now - probe.at).rem_euclid(period) >= on;
@@ -637,24 +475,18 @@ fn fire_probe_hover(
let (dx, dy) = jitter_offset(probe.jitter, probe.i);
script.mouse_move(x + dx, y + dy);
}
// Named but unresolved is worth saying once per pass rather than silently sweeping air —
// a sweep over frames that do not exist reads as "hovering is free".
// A sweep over missing frames would read as free hovering, so say so.
None => warn!("probe-hover: {name} has no resolved rect — nothing hovered this step"),
}
}
/// A deterministic offset inside a `j`-pixel square, cycling with the step index.
///
/// It exists to separate the two costs a hover sweep otherwise fuses: **the pointer moved** (the
/// hit test re-runs) and **the hovered frame changed** (the tooltip is torn down and rebuilt). A
/// one-name sweep with a jitter moves the pointer every step while the hovered frame — and the
/// tooltip on it — stay put, which is the first of the two on its own.
/// A deterministic offset inside a `j`-pixel square: the pointer moves every step while the
/// hovered frame stays put, isolating hit-test cost from tooltip rebuilds.
fn jitter_offset(j: f32, i: usize) -> (f32, f32) {
if j <= 0.0 {
return (0.0, 0.0);
}
// A 4-phase square walk: (+,+) → (-,+) → (-,-) → (+,-). Every step is a real move, and the
// cursor never leaves a `j`-pixel box around the centre.
// A 4-phase square walk: (+,+), (-,+), (-,-), (+,-).
let (sx, sy) = match i % 4 {
0 => (1.0, 1.0),
1 => (-1.0, 1.0),
@@ -697,8 +529,9 @@ impl Plugin for ProbeDragPlugin {
}
}
/// [`ProbeDragPlugin`]'s state machine: which pair, which step of the gesture, and when the next
/// step is due.
/// [`ProbeDragPlugin`]'s state: `WOW_PROBE_DRAG="A>B[;C>D…]"` drags frame A onto B through the
/// real pointer path from `WOW_PROBE_DRAG_AT` (default 14 s), one step per `WOW_PROBE_DRAG_STEP`
/// (default 0.1 s); `WOW_PROBE_DRAG_LUA` is a chunk whose returned string is logged after each.
#[derive(Resource)]
struct ProbeDrag {
pairs: Vec<(String, String)>,
@@ -706,16 +539,15 @@ struct ProbeDrag {
step: f32,
report: String,
pair: usize,
/// The step within the current gesture — see [`fire_probe_drag`]'s table.
/// The step within the current gesture, per [`fire_probe_drag`].
phase: usize,
next: f32,
from: (f32, f32),
to: (f32, f32),
}
/// One frame's centre in the VM's own units, read out of the live layout (`GetLeft`/`GetRight`/
/// `GetTop`/`GetBottom` — the same space [`crate::ui_script::input`] feeds the cursor in).
/// `None` when the frame does not exist or has no resolved rect.
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, in the space
/// [`crate::ui_script::input`] feeds the cursor in.
fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> {
let read = |edge: &str| {
script
@@ -730,11 +562,8 @@ fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f3
))
}
/// Advance the scripted drag by at most one step per `probe.step` seconds.
///
/// The gesture is deliberately spread across real frames rather than run in one call: a press and
/// a release inside a single frame is a *click*, and the whole point here is to exercise what the
/// app does BETWEEN them.
/// Advances the drag at most one step per `probe.step` seconds, each on its own frame: a press
/// and release in one frame is a click.
fn fire_probe_drag(
mut probe: ResMut<ProbeDrag>,
mut synthetic: ResMut<crate::ui_script::SyntheticPointer>,
@@ -762,8 +591,6 @@ fn fire_probe_drag(
let (from_name, to_name) = probe.pairs[probe.pair].clone();
match probe.phase {
// Read both centres and park the cursor on the source — the hover the player makes
// before they press.
0 => {
let (Some(from), Some(to)) = (
frame_centre(&script, &from_name),
@@ -785,13 +612,12 @@ fn fire_probe_drag(
let (x, y) = probe.from;
script.mouse_button(x, y, "LeftButton", true);
}
// Past the 4-px drag threshold — this is the move that fires `OnDragStart`.
// Past the 4-px drag threshold: fires `OnDragStart`.
2 => {
let (x, y) = probe.from;
script.mouse_move(x + 8.0, y + 8.0);
}
// Two waypoints along the path, so anything that only breaks while the drag is IN FLIGHT
// (a repaint that re-seats the row under the cursor, say) gets frames to do it in.
// Two waypoints, so the drag spends frames in flight.
3 | 4 => {
let t = if probe.phase == 3 { 0.4 } else { 0.8 };
let (fx, fy) = probe.from;
@@ -806,10 +632,8 @@ fn fire_probe_drag(
let (x, y) = probe.to;
script.mouse_button(x, y, "LeftButton", false);
}
// The self-check: hover the source where it now DRAWS and ask who the hit test says is
// there. A frame whose hit rect and drawn rect have parted company is invisible to any
// harness that presses the centre it just read — and it is exactly what "I can't grab
// anything any more" looks like from a hand on the mouse.
// Self-check: hover the source where it now draws and ask the hit test who is there, to
// catch a hit rect that has parted from the drawn one.
7 => {
if let Some((x, y)) = frame_centre(&script, &from_name) {
script.mouse_move(x, y);
@@ -840,8 +664,7 @@ fn fire_probe_drag(
}
probe.pair += 1;
probe.phase = 0;
// Hand the pointer back between gestures: the app's own feed runs for a frame, which
// is what a player's hand does too, and is where a stale hover would show up.
// Hand the pointer back for a frame between gestures, where a stale hover shows.
synthetic.0 = false;
return;
}
@@ -853,8 +676,7 @@ fn fire_probe_drag(
mod tests {
use super::{probe_lua_due, ProbeLua};
/// A probe configured the way `WOW_PROBE_LUA=… WOW_PROBE_LUA_AT=10` configures one, with the
/// per-entry bookkeeping set by hand.
/// A probe as `WOW_PROBE_LUA_AT=10` configures one, with its entry bookkeeping set by hand.
fn probe(entries: u32, fired: u32, ready_at: f32) -> ProbeLua {
ProbeLua {
chunk: "ProbeLog('x')".into(),
@@ -867,8 +689,6 @@ mod tests {
}
}
/// The first entry is still `WOW_PROBE_LUA_AT` on the PROCESS clock — the meaning every
/// recorded probe line in `decisions/` is tuned against.
#[test]
fn first_fire_waits_for_the_process_delay() {
let p = probe(1, 0, 2.0);
@@ -876,23 +696,18 @@ mod tests {
assert!(probe_lua_due(&p, 10.0), "at WOW_PROBE_LUA_AT");
}
/// Nothing fires before there is a world entry to fire into — the login and character
/// screens sit here however long they take.
#[test]
fn nothing_fires_before_the_first_entry() {
assert!(!probe_lua_due(&probe(0, 0, 0.0), 60.0));
}
/// One chunk per entry, and no more: the frames after a fire are not a second one.
#[test]
fn an_entry_takes_exactly_one_chunk() {
assert!(!probe_lua_due(&probe(1, 1, 2.0), 60.0));
assert!(!probe_lua_due(&probe(2, 2, 40.0), 90.0));
}
/// **The gap 2116 hit head-on**: the second entry re-arms, on its OWN clock. The process
/// delay is long past by the time a relog lands, so reusing it would fire the chunk into the
/// loading screen; the settle is measured from the moment that entry could take a chunk.
/// A later entry re-arms on its own settle, measured from when it became ready.
#[test]
fn a_later_entry_re_arms_on_its_own_clock() {
let p = probe(2, 1, 40.0);
@@ -1,38 +1,13 @@
//! The **dress census** (`WOW_DRESS_CENSUS=<secs>[,<every>]`) — one line per streamed player near
//! the body saying what the wire asked for, what we resolved, and what is actually hanging off the
//! skeleton right now.
//! `WOW_DRESS_CENSUS=<secs>[,<every>]`: one line per streamed player near the body giving what the
//! wire asked for, what we resolved and what hangs off the skeleton.
//!
//! 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.
//! - `flags`/`hide`: `PLAYER_FLAGS` and its `HIDE_HELM 0x400`/`HIDE_CLOAK 0x800` bits, a public
//! field, so every player in range is readable.
//! - `helm`/`cloak`: the resolved `ItemDisplayInfo` ids ([`Equipment`]); `0` is not dressed.
//! - `spawned`: the attach slots standing under the unit ([`HeldAttached::spawned_slots`]).
//! - `contradictions`: bodies whose flags hide a piece that is dressed anyway, tagged `!!`.
//!
//! ```text
//! DRESS_CENSUS t=20.0 players=2 hiding-helm=1 hiding-cloak=1 contradictions=0 radius=120 body=(-8949.95,-132.49,83.57)
//! DRESS 0xF150000000000038 d= 0.0 flags=0x00020c02 hide=helm+cloak helm=0 cloak=0 settled=1 spawned=[main,shL,shR] Probethree
//! DRESS 0xF150000000000079 d= 12.4 flags=0x00020002 hide=- helm=32154 cloak=41205 settled=1 spawned=[main,off,helm,shL,shR] Naz
//! ```
//!
//! - **`flags` / `hide`** are the wire's own: `PLAYER_FLAGS` and its `HIDE_HELM 0x400` /
//! `HIDE_CLOAK 0x800` bits, which are PUBLIC — so every player in range is readable here, not
//! only our own body, and "does a remote player's preference reach us" is a line rather than an
//! argument.
//! - **`helm` / `cloak`** are the resolved `ItemDisplayInfo` ids ([`Equipment`]) — `0` means the
//! body is dressed without that piece, whether because nothing is equipped there or because the
//! preference suppressed it.
//! - **`spawned`** is the attach-slot list actually standing under the unit
//! ([`HeldAttached::spawned_slots`]) — the visual truth, read off the ECS rather than inferred.
//! - **`contradictions`** is the headline number and the whole point: a body whose flags say hide
//! while a `helm` display or a spawned `helm` slot says otherwise is tagged `!!` and counted.
//! B123 reproduces as `contradictions=1`; the fix reads `contradictions=0` with `hide=` still
//! set, which is the distinction "the helm is gone" alone cannot make (it is also what a naked
//! head looks like when the *preference* never arrived).
//!
//! ```text
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=<geared body> WOW_NOSOUND=1 \
//! WOW_DRESS_CENSUS="20,10" WOW_PROBE_EXIT_AT=45 cargo run -q -p benilla
//! ```
//! How to run it: `docs/CONTRIBUTING.md`, "Running it unattended".
use benilla_assets::coords::bevy_to_wow;
use benilla_protocol::EntityKind;
@@ -43,8 +18,7 @@ use crate::entities::{Equipment, HeldAttached, ATTACH_SLOT_NAMES};
use crate::names::NameCache;
use crate::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
/// How far from the body the census looks, in yards — the unit-visual census's radius, for the
/// same reason: comfortably past what the server streams, so an empty census means an empty scene.
/// Census radius in yards, past what the server streams; `WOW_DRESS_CENSUS_RADIUS` overrides.
const DEFAULT_RADIUS: f32 = 120.0;
pub(crate) struct DressCensusPlugin;
@@ -62,15 +36,14 @@ impl Plugin for DressCensusPlugin {
}
}
/// [`DressCensusPlugin`] state: when the next census fires, and how often after that (`0` = once).
/// [`DressCensusPlugin`] state; `every` of 0 fires once.
#[derive(Resource)]
struct DressCensus {
next: f32,
every: f32,
}
/// What the census reads per entity: identity, kind, pose, the resolved worn set, and the attach
/// roots actually standing.
/// What the census reads per entity.
type DressQuery = (
Entity,
&'static Guid,
@@ -87,14 +60,10 @@ fn fire_dress_census(
time: ProbeClock,
names: Res<NameCache>,
body: Query<(Entity, &Transform), With<SelfPlayer>>,
// The self body's parts, one line each (`DRESS_PART`) — the draw-population inventory.
// The self body's parts, one `DRESS_PART` line each.
body_parts: crate::entities::BodyPartsDesc,
entities: Query<DressQuery>,
// The body's draw population (`parts=`/`mats=`): every mesh part hanging under the unit —
// body batches, each attach model's batches, cards — and how many DISTINCT materials
// they bind. The gap between the two is the ceiling on merging a body's batches by
// material (1929's first proposal): what a merge could collapse, read off a dressed body
// instead of guessed from a model's batch table.
// `parts=`/`mats=`: mesh parts under the unit and the distinct materials they bind.
children: Query<&Children>,
parts: Query<&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>,
) {
@@ -116,7 +85,7 @@ fn fire_dress_census(
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(DEFAULT_RADIUS);
// `(is_contradiction, sort key, line)`. A contradiction sorts to the top: it is the finding.
// `(is_contradiction, sort key, line)`; contradictions sort first.
let mut rows: Vec<(bool, i64, String)> = Vec::new();
let (mut hiding_helm, mut hiding_cloak, mut bad) = (0u32, 0u32, 0u32);
for (unit, guid, net, t, store, equipment, attached) in &entities {
@@ -137,9 +106,7 @@ fn fire_dress_census(
.enumerate()
.filter_map(|(i, e)| e.map(|_| ATTACH_SLOT_NAMES[i]))
.collect();
// The contradiction: the wire asked for a piece to be hidden and it is dressed anyway —
// either resolved onto the body or standing as an attach model. This is B123's symptom,
// stated as a predicate instead of a screenshot.
// The wire hides a piece that is resolved onto the body or standing as an attach model.
let helm_shown = eq.helm != 0 || spawned.contains(&"helm");
let contradiction = (hide_helm && helm_shown) || (hide_cloak && eq.cloak != 0);
bad += u32::from(contradiction);
@@ -1,18 +1,7 @@
//! `WOW_FX_CENSUS=1` — where this frame's particle draws are actually addressed, and whether the
//! view they name is switched on.
//!
//! Built to answer "the login screen's braziers are simulating but nothing is on screen." A draw
//! record names the **main-world camera entity** whose view it belongs to
//! ([`benilla_world::particles::buffer::EffectDraw::cam`]), and `particles::sim` resolves that for a booth-layered
//! emitter by finding the first booth camera whose `RenderLayers` intersect the emitter's. If two
//! booths share a layer, the wrong camera can win — and if that camera is inactive, the emitter
//! simulates forever and never draws. Nothing about that is visible in a frame time, a trace, or
//! the emitter's own state: the particles are all *correct*, just addressed to a view nobody is
//! rendering. The census makes it a line of output (decision 0775).
//!
//! Prints every 2 s: frames, live vertex count, how many DISTINCT vertex-buffer states were seen
//! (the liveness check — a frozen sim repeats one state), and the per-camera draw histogram with
//! each camera's booth token and active flag.
//! `WOW_FX_CENSUS=1`: which camera each particle draw is addressed to, and whether that camera
//! is active. A booth emitter takes the first booth camera sharing its `RenderLayers`, so two
//! booths on one layer can route draws to an inactive view. Prints every 2 s: frames, vertex
//! count, distinct vertex-buffer states (a frozen sim repeats one) and the per-camera histogram.
use std::collections::{HashMap, HashSet};
@@ -22,7 +11,7 @@ use bevy::time::Real;
use crate::portrait::BoothCam;
use benilla_world::particles::buffer::EffectQuads;
/// Off unless `WOW_FX_CENSUS=1` — the plugin adds no system at all otherwise.
/// Adds no system unless `WOW_FX_CENSUS=1`.
pub(crate) fn plugin(app: &mut App) {
if std::env::var("WOW_FX_CENSUS").as_deref() != Ok("1") {
return;
@@ -41,8 +30,7 @@ fn census(
use std::hash::{Hash, Hasher};
let Some(quads) = quads else { return };
*frames += 1;
// A cheap fingerprint of the head of the vertex buffer: enough to tell "this frame differs
// from the last" without hashing megabytes.
// Fingerprints only the head of the vertex buffer: enough to tell frames apart.
let mut h = std::collections::hash_map::DefaultHasher::new();
quads.verts.len().hash(&mut h);
for v in quads.verts.iter().take(600) {
@@ -56,10 +44,7 @@ fn census(
*last = time.elapsed_secs();
let mut per_cam: HashMap<Entity, usize> = HashMap::new();
// How many of this frame's draws take the SCENE-LIT pipeline arm. Only ~5% of the emitter
// corpus clears the unlit file bit, so "the lit variant compiles" and "the lit variant ever
// runs" are entirely different claims — and a scene with no lit emitter in it cannot tell
// them apart. This counter is what makes the second one checkable.
// Draws on the scene-lit pipeline arm: only ~5% of emitters clear the unlit bit.
let mut lit_draws = 0usize;
for d in &quads.draws {
*per_cam.entry(d.cam).or_default() += 1;
@@ -1,43 +1,14 @@
//! The **under-floor census** (`WOW_GROUND_CENSUS=<secs>[,<every>]`) — the instrument that closes
//! B197's symptom: *"±half the NPCs in one building sit below the floor"*.
//! `WOW_GROUND_CENSUS=<secs>[,<every>]`: per ground-clamped body ([`ground_derived`], including a
//! player the server moves on a spline), whether it sits below the pose the server sent.
//!
//! Every reported site of that bug was a picture. A picture cannot say whether a unit is below the
//! floor **because the server put it there** (then it is the server's ledger) or because *we* pulled
//! it down off the pose the server sent (then it is ours), and it cannot say whether the floor the
//! unit belongs on is even collidable from where it stands. Both questions are one line of numbers
//! per unit:
//! - `seat`: the server's Z ([`GroundClamped::seat_y`]), before the ground clamp.
//! - `drop`: `seat − z`, how far the clamp pulled the unit down; `sunk` counts those past
//! [`SUNK_YD`].
//! - `above`: the lowest walkable surface strictly above the feet.
//! - `terrain`: the MCNK height under it, what the clamp finds before a building's collider loads.
//!
//! ```text
//! UGD 0x… unit "Stormwind Guard" z=5.02 seat=9.14 drop=+4.12 terrain=5.02 above=9.07 (+4.05) spline=0 swim=0
//! ```
//!
//! - **`seat`** is the server's own Z for this unit — the pose the create block / a move packet /
//! its spline last wrote ([`GroundClamped::seat_y`]), before the ground clamp had its say.
//! - **`drop`** is `seat − z`: how far the clamp pulled the unit *below* the server's pose. This is
//! the number that names the bug; on a healthy scene it is within a yard either way (the clamp's
//! whole job is a small correction), and `sunk` counts the ones past [`SUNK_YD`].
//! - **`above`** is the lowest walkable surface strictly above the unit's feet, found by walking a
//! one-sided down-ray through the stack. A unit with a floor right there and a big `drop` is
//! standing under a floor it could be standing on — the exact shape of the report.
//! - **`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 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 \
//! WOW_PROBE_CHAT=".go xyz 6558.80 457.23 9.07 1" \
//! WOW_GROUND_CENSUS="30,15" WOW_PROBE_EXIT_AT=75 cargo run -q -p benilla
//! ```
//!
//! The repeat form is the load-race half: a census at 30 s and again at 45 s says whether a sunk
//! unit *stays* sunk once the world has finished arriving, which is what separates a transient from
//! the ratchet decision 1384 removed.
//!
//! It lists exactly the bodies the clamp is responsible for ([`ground_derived`]) — since B357 that
//! includes a **player the server moves along a spline**, which the `player` tag names. That is the
//! whole readout for a Playerbots server: a bot mid-path reads `player spline=1` with a small
//! `drop`, and the bug it closes read the wire Z with no row here at all.
//! The repeat form tells a transient sink during loading from one that stays. How to run it:
//! `docs/CONTRIBUTING.md`, "Running it unattended".
use benilla_assets::coords::bevy_to_wow;
use benilla_protocol::EntityKind;
@@ -51,18 +22,13 @@ use crate::net::{
SelfPlayer, Spline,
};
/// A drop past this (yd) counts as **sunk** — the headline count. The clamp's honest corrections are
/// the small float off a slightly-high wire Z and the little hill a straight spline cuts through;
/// half a yard is well clear of both and well under a storey.
/// A drop past this (yd) counts as sunk: clear of the clamp's small corrections, under a storey.
const SUNK_YD: f32 = 0.5;
/// How far above a unit's feet the stack walk starts looking for the floor it should be on. A
/// storey and a bit: high enough to clear any building floor a unit could be sunk beneath, low
/// enough that it doesn't report the roof of an unrelated structure.
/// Where the stack walk starts above the feet (yd): past one storey, below most roofs.
const CEILING_YD: f32 = 12.0;
/// Feet-clearance for "strictly above" in the stack walk — below this the surface *is* the one the
/// unit is standing on, not one above it.
/// A surface within this of the feet (yd) is the one the unit stands on.
const ABOVE_EPS: f32 = 0.05;
pub(crate) struct GroundCensusPlugin;
@@ -88,8 +54,7 @@ impl Plugin for GroundCensusPlugin {
}
}
/// [`GroundCensusPlugin`] state: when the next census fires, how often after that (`0` = once), and
/// how far from the body to look.
/// [`GroundCensusPlugin`] state; `every` of 0 fires once, `radius` from `WOW_GROUND_CENSUS_RADIUS`.
#[derive(Resource)]
struct GroundCensus {
next: f32,
@@ -97,8 +62,7 @@ struct GroundCensus {
radius: f32,
}
/// What the census reads per unit: identity, kind, pose, the clamp's memo (the seat), whether a
/// path is driving it, and whether it is swimming (which exempts it from the clamp).
/// What the census reads per unit; a swimmer is exempt from the clamp.
type CensusQuery = (
&'static Guid,
&'static NetEntity,
@@ -106,21 +70,17 @@ type CensusQuery = (
Option<&'static GroundClamped>,
Option<&'static Spline>,
Has<CreatureSwimming>,
// The subject test's other two inputs, so the listing is exactly the clamp's own population
// rather than a second, hand-kept guess at it.
// The rest of `ground_derived`'s inputs, so the listing is the clamp's own population.
Has<Embodied>,
Has<RemoteMotion>,
);
/// One census line per [`ground_derived`] body within [`GroundCensus::radius`] of ours, worst drop
/// first, under a summary line naming the count that matters.
/// One line per [`ground_derived`] body within [`GroundCensus::radius`], worst drop first, under
/// a summary line.
fn fire_ground_census(
mut probe: ResMut<GroundCensus>,
time: ProbeClock,
// The name each row belongs to. "Which NPC is sunk" is a name question — a guid sends the
// reader back to the server's own tables to answer it — and the cache is already warm: it is
// asked at first *sight* for every unit that streams in. A `None` is a name still in flight,
// never a missing one.
// Warm from first sight of every unit; a `None` is a name still in flight.
names: Res<NameCache>,
net_commands: Res<NetCommands>,
world: benilla_world::collision::WorldCollision,
@@ -172,16 +132,13 @@ fn fire_ground_census(
"UGD {:#018x} {} {name:?} display={} pos=({:.2},{:.2},{:.2}) z={z:.2} seat={seat:.2} \
drop={drop:+.2} terrain={} above={} spline={} swim={}",
guid.0,
// Which kind this row is: since B357 the listing is not all creatures, and a
// player row is the one a Playerbots report is about.
// The listing includes spline-driven players.
if net.kind == EntityKind::Player {
"player"
} else {
"unit"
},
// The display id, so a row hands straight to `WOW_CLAMP_TRACE=<display>` — the
// per-frame `clmp` readout is filtered by display and nothing else, and a census
// row that names only a guid cannot be followed up with it.
// The key `WOW_CLAMP_TRACE=<display>` filters its per-frame readout by.
net.display_id.unwrap_or(0),
wow[0],
wow[1],
@@ -194,9 +151,7 @@ fn fire_ground_census(
));
}
rows.sort_by(|a, b| b.0.total_cmp(&a.0));
// A swimmer is EXEMPT from the clamp by design (its wire Z is its swim depth), so its seat is
// simply the last Z the clamp saw before it entered the water — a stale memo, not a drop. It
// stays in the listing and out of the count.
// A swimmer's wire Z is its swim depth and its seat is stale: listed, not counted.
let sunk = rows
.iter()
.filter(|(d, swim, _)| *d > SUNK_YD && !swim)
@@ -220,11 +175,8 @@ fn fire_ground_census(
}
}
/// The lowest walkable surface **strictly above** `feet` — the floor a sunk unit should be standing
/// on. Walks the one-sided down-ray stack from [`CEILING_YD`] overhead: each hit above the feet
/// becomes the new candidate and the next cast starts just under it, so a unit beneath three
/// storeys reports the one immediately over its head rather than the roof. `None` when nothing is
/// overhead — the healthy outdoor case.
/// The lowest walkable surface strictly above `feet`: casts down from [`CEILING_YD`] overhead,
/// restarting just under each hit, so the nearest floor overhead wins over the roof.
fn lowest_surface_above(
world: &benilla_world::collision::WorldCollision,
feet: Vec3,
@@ -237,10 +189,8 @@ fn lowest_surface_above(
break;
}
let origin = Vec3::new(feet.x, from, feet.z);
// A miss ENDS the walk with what it already has — it never discards it. `?` here read as
// "no floor above this unit" for three of B197's four sunk NPCs, whose second cast (from
// just under the floor it had already found, down to feet sitting on the terrain) missed
// that terrain by a float hair. The instrument's own first finding was its own bug.
// A miss ends the walk keeping what it found: a cast can miss the feet's own surface by
// a float hair.
let Some(hit) = world.ray_body(origin, Dir3::NEG_Y, reach) else {
break;
};
+74 -186
View File
@@ -1,40 +1,11 @@
//! The **motion-jitter meter** (`WOW_JITTER=<name-substr>[,<start_s>]`) — the instrument that turns
//! "that NPC looks shaky up close" into a number, and separates the three things a screenshot
//! conflates: the camera moving, the unit's root moving, and the *pose* moving.
//! `WOW_JITTER=<name-substr>[,<start_s>]`: per frame, the camera, root and pose terms of a
//! subject's rendered position as first and second differences, in mm and in pixels at the
//! subject's distance. Δ² separates noise (≈2n) from smooth motion (≈A/3600 at 60 Hz), and `Δ/dt`
//! beside `dt` separates uneven frame timing from a noisy pose.
//!
//! A rendered bone's screen position is `pose · root · (origin − camera)`. Each of those three
//! terms is sampled here every frame and reported as its own **first and second differences**:
//! The `mm` columns are milli-yards (yards × 1000), not millimetres; pixel columns are unaffected.
//!
//! - **Δ (first difference)** is motion. A slow idle has one; so does noise. On its own it cannot
//! tell them apart, which is why "the model moved 0.4 mm this frame" is not a finding.
//! - **Δ² (second difference)** is *curvature per frame*, and that is the discriminator. Smooth
//! motion at 60 Hz has almost none — a 2 s idle of amplitude `A` carries `A·ω²·dt² ≈ A/3600`,
//! i.e. tens of microns for a centimetre-scale sway — while uncorrelated per-frame noise of
//! amplitude `n` shows up at `≈2n`. Two orders of magnitude apart, from the same series.
//! - **Δ(v) = Δ²/dt²** separates *arithmetic* noise from **judder**: if the pose is smooth in time
//! but the frame deltas are not, `Δ` is ragged while the velocity `Δ/dt` is clean. That is the
//! whole difference between "the numbers are wrong" and "the numbers are right and arriving at
//! uneven times", and it is invisible to any instrument that does not log `dt` beside the pose.
//!
//! **The `mm` columns are milli-YARDS** — yards × 1000, the map unit's own thousandth. On a true
//! 0.9144 m yard the f32 ULP at Elwynn is 0.893 mm, not the 0.9766 these columns print (decision
//! 1618). The pixel columns are unaffected: they are computed from yards throughout, so the unit
//! cancels. Kept as-is rather than renamed because these columns are the comparison surface for two
//! landed records (1609, 1617) and a rescale would silently break every figure in them.
//!
//! Everything is reported in **millimetres and in pixels at the subject's own distance**, because
//! a world-space wobble is only a defect in proportion to how many pixels it covers: the same
//! 1 mm is 0.03 px across a courtyard and 0.6 px pressed against the camera, which is exactly why
//! this class of report always arrives as "…when I'm zoomed right in".
//!
//! ```text
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
//! WOW_PROBE_CHAT=".go xyz -9481.53 76.10 56.57 0" \
//! WOW_PROBE_CAM="-82.55,0,0@18" WOW_JITTER="guard,22" WOW_PROBE_EXIT_AT=40 cargo run -q -p benilla
//! ```
//!
//! One `JIT` line per frame, TSV, so a series is read by a script rather than by eye. The columns
//! are named in the `JIT#` header the meter prints when it arms.
//! One `JIT` TSV line per frame; the `JIT#` header names the columns when the meter arms.
use bevy::camera::Projection;
use bevy::prelude::*;
@@ -52,72 +23,49 @@ use crate::net::{Guid, NetEntity, SelfPlayer};
/// `WOW_JITTER`'s parsed knobs plus the two-frame history the differences are taken over.
#[derive(Resource)]
struct JitterMeter {
/// Lower-cased substring of the subject's name; empty = the nearest rigged non-self unit.
/// Lower-cased name substring; empty is the nearest rigged non-self unit, `self` the avatar.
want: String,
/// Wall-clock second the meter arms (the world has to have streamed in first).
/// Wall-clock second the meter arms.
at: f32,
/// Whose history [`Self::prev`] holds — a different entity restarts the series.
/// Whose history [`Self::prev`] holds; a different entity restarts the series.
subject: Option<Entity>,
/// Per-bone model-space translations, last frame and the one before.
prev: Vec<Vec3>,
prev2: Vec<Vec3>,
/// **What a vertex does** — each bone's row applied to a probe point [`FLESH_R`] out along
/// each local axis, same two-frame window. A bone origin can be bitwise still while the bone
/// *spins*, and a standing idle is very nearly a pure rotation animation: differencing origins
/// measures the one channel such a clip does not use. This is the series the eye reads.
/// Each bone's matrix applied to a point out along each local axis ([`flesh_radii`]): a
/// standing idle is nearly pure rotation, which bone origins alone miss.
prev_flesh: Vec<Vec3>,
prev2_flesh: Vec<Vec3>,
/// This subject's per-bone flesh radii ([`flesh_radii`]) — bind data, so computed once.
/// This subject's per-bone flesh radii ([`flesh_radii`]), computed once.
radii: Vec<f32>,
/// Last frame's **animation** clock: `(node, seek_time, weight)` per playing animation.
///
/// The blind spot this closes. [`ProbeClock`] is `Time<Real>` — the WALL clock — so every `dt`
/// this meter has ever printed, and the whole `Δ/dt` judder discriminator built on it, divides
/// by a clock the pose does not advance on. A pose stepping unevenly against a perfectly clean
/// wall delta is invisible to every other column here, and that is exactly the shape a
/// whole-skeleton twitch takes. `seek_time` is the clock the pose is actually sampled at.
/// Last frame's `(node, seek_time, weight)` per playing animation: `seek_time` is the clock
/// the pose samples at, where `dt` is the wall clock.
prev_anim: Vec<(usize, f32, f32)>,
/// The same two-frame window on the unit's root and on the camera, world space.
/// The unit's root and the camera, world space.
prev_root: Vec3,
prev2_root: Vec3,
prev_cam: Vec3,
prev2_cam: Vec3,
/// The consumer **anchors'** world translations (held items, helm, shoulders, cape — the
/// entity lane 0974 left in absolute space), same two-frame window.
/// The anchors' world translations (held items, helm, shoulders, cape), in absolute space.
prev_anc: Vec<Vec3>,
prev2_anc: Vec<Vec3>,
/// The **rider** frames the vertex stage actually places an attached model with (1609): the
/// row-0 translation, in the host's rig frame. The A/B against `prev_anc` above — same
/// attachment, same frame, one measured in absolute world space and one in the rig's.
/// The rider frames the vertex stage places an attached model with: row-0 translation in the
/// host's rig frame.
prev_rid: Vec<Vec3>,
prev2_rid: Vec<Vec3>,
/// **The rows the vertex stage actually blends** — the palette, probed at each bone's bind
/// position, same two-frame window.
///
/// The blind spot this closes, and it is the one that let a wrong "fixed" call stand. Every
/// other bone column here reads `RigPose::model`, the pose the compose pass produced — and
/// the GPU never reads it. It skins from the palette, which `rig_anim::compose::rig_worlds`
/// composes a SECOND time from the rig's locals through the root's own frame, folding in the
/// `flags & 0x7` arm and the billboard replacement. Anything that goes wrong between those
/// two compositions is invisible to `flesh`, so a clean `flesh` column has never been able to
/// clear the body — it only ever cleared the pose.
/// The palette rows the GPU skins from, probed at each bone's bind position. The palette is
/// composed again by `rig_anim::compose::rig_worlds` (the `flags & 0x7` arm, billboards), so
/// a clean `flesh` column does not clear it.
prev_pal: Vec<Vec3>,
prev2_pal: Vec<Vec3>,
/// **The end of the chain: where each probe lands ON SCREEN, in pixels.** Every other column
/// stops somewhere upstream — model space, rig space, world space — and each of those can be
/// spotless while the thing the eye reads is not, because the camera's ORIENTATION, the
/// projection and the viewport all sit between them and the glass. This series is the shader's
/// own arithmetic (`view_rot * (p_rig + (origin - cam))`, then clip) carried to viewport
/// pixels, so it is the only column that can be held against "I can see it move".
/// Each palette probe on screen in pixels, by the shader's own arithmetic
/// (`view_rot * (p_rig + (origin - cam))`, then clip and viewport).
prev_scr: Vec<Vec2>,
prev2_scr: Vec<Vec2>,
/// The camera's ROTATION, same two-frame window. `prev_cam` above is its translation only —
/// and a camera that never moves an inch can still swing the whole scene a fraction of a pixel
/// by rolling a single ULP, which reads as "everything shimmers" and is invisible to a
/// translation probe.
/// The camera's rotation: one ULP of roll shifts the whole scene without any translation.
prev_camrot: Quat,
prev2_camrot: Quat,
/// Each bone's BIND position (from the rig's inverse bindposes) — bind data, computed once.
/// Each bone's bind position, from the rig's inverse bindposes; computed once.
binds: Vec<Vec3>,
/// Frames of history held (`< 2` means Δ² is not defined yet).
depth: u32,
@@ -128,8 +76,7 @@ pub(crate) struct JitterMeterPlugin;
impl Plugin for JitterMeterPlugin {
fn build(&self, app: &mut App) {
let raw = std::env::var("WOW_JITTER").unwrap_or_default();
// `<substr>`, `<substr>,<start>`, or a bare `<start>` — the subject is optional because
// "the nearest rigged thing that isn't me" is the whole selection at a 1.6 yd close-up.
// `<substr>`, `<substr>,<start>`, or a bare `<start>`.
let (want, at) = match raw.rsplit_once(',') {
Some((n, t)) => (n.trim(), t.trim().parse::<f32>().unwrap_or(0.0)),
None => match raw.trim().parse::<f32>() {
@@ -164,28 +111,18 @@ impl Plugin for JitterMeterPlugin {
binds: Vec::new(),
depth: 0,
})
// `Last`, so every writer that can still move the subject this frame — the pose evaluator,
// the rig finalize, transform propagation, the camera seat — has already run. Sampling in
// `Update` would measure last frame's world against this frame's pose (the 1398 seam).
// `Last`: after the pose, rig finalize, transform propagation and camera seat have run.
.add_systems(Last, sample_jitter);
}
}
/// A leaf bone's assumed flesh radius, model-space yards — a fingertip or an eyelid, not a limb.
/// A leaf bone's assumed flesh radius, model-space yards.
const LEAF_R: f32 = 0.05;
/// The cap on a derived radius: no bone drives geometry further out than this on a humanoid.
/// The cap on a derived radius, model-space yards.
const MAX_R: f32 = 0.45;
/// **Per-bone flesh radius** — how far off its own axis this bone's geometry actually sits, taken
/// as the distance to its farthest child (a bone's length is the best proxy we have here for the
/// flesh it carries), leaves falling back to [`LEAF_R`]. The child's own local translation IS that
/// distance; a standing clip animates rotation, so this is the bind offset in all but name.
///
/// This is load-bearing, not a detail. A single radius for all 117 bones reads a 25 mrad twitch of
/// an *eyelid* bone as ~5 mm of motion, when the eyelid's own vertices sit ~0.02 yd out and move a
/// tenth of that. Sized this way, the probe answers "how far did the geometry this bone drives
/// move", which is the question, instead of "how far did a point 0.2 yd off it move", which
/// flatters every small bone in the skeleton into looking like the worst thing on the model.
/// Per-bone flesh radius: the distance to its farthest child, clamped to [`LEAF_R`]..[`MAX_R`].
/// Sized per bone so a small bone's rotation is not read at a large bone's lever arm.
fn flesh_radii(parents: &[i16], locals: &[Transform]) -> Vec<f32> {
let mut r = vec![0.0f32; parents.len()];
for (child, &p) in parents.iter().enumerate() {
@@ -199,8 +136,7 @@ fn flesh_radii(parents: &[i16], locals: &[Transform]) -> Vec<f32> {
r.iter().map(|&v| v.clamp(LEAF_R, MAX_R)).collect()
}
/// The probe points for one composed rig: three per bone, one along each local axis, each at that
/// bone's own [`flesh_radii`] distance.
/// Three probe points per bone, one along each local axis at its [`flesh_radii`] distance.
fn flesh_probes(model: &[bevy::math::Affine3A], radii: &[f32]) -> Vec<Vec3> {
let mut out = Vec::with_capacity(model.len() * 3);
for (i, m) in model.iter().enumerate() {
@@ -212,7 +148,7 @@ fn flesh_probes(model: &[bevy::math::Affine3A], radii: &[f32]) -> Vec<Vec3> {
out
}
/// What the meter reads per candidate subject: identity, name key, world pose, composed rig.
/// What the meter reads per candidate subject.
type SubjectQuery = (
Entity,
&'static Guid,
@@ -223,12 +159,8 @@ type SubjectQuery = (
Has<SelfPlayer>,
);
/// **The palette probe** — [`flesh_probes`]' twin on the rows the vertex stage blends. A row is
/// `rig_from_joint × inverse_bindpose`, so it maps BIND space, not bone-local space: probing it at
/// the bone's bind position plus [`flesh_radii`]'s offset is the same question `flesh_probes` asks
/// of `RigPose::model`, put to the numbers that actually reach the GPU. Probing it at the row's own
/// translation column instead would read a rotation as up to the model's full height of lever arm,
/// ~10x what any real vertex sees.
/// [`flesh_probes`] on the palette rows. A row is `rig_from_joint × inverse_bindpose`, mapping bind
/// space, so it is probed at the bone's bind position plus the flesh offset.
fn palette_probes(rows: &[Mat4], binds: &[Vec3], radii: &[f32]) -> Vec<Vec3> {
let mut out = Vec::with_capacity(rows.len() * 3);
for (i, m) in rows.iter().enumerate() {
@@ -241,7 +173,7 @@ fn palette_probes(rows: &[Mat4], binds: &[Vec3], radii: &[f32]) -> Vec<Vec3> {
out
}
/// One `JIT` line per frame for the subject: the three terms' Δ and Δ², in mm and in pixels.
/// One `JIT` line per frame for the subject.
fn sample_jitter(
mut meter: ResMut<JitterMeter>,
time: ProbeClock,
@@ -257,9 +189,7 @@ fn sample_jitter(
palettes: Res<benilla_world::rig_palette::RigPalettes>,
ibps: Res<Assets<SkinnedMeshInverseBindposes>>,
players: Query<&AnimationPlayer>,
// The clock the POSE advances on — `ProbeClock` above is `Time<Real>`, the wall clock, and the
// gap between the two is the whole defect (`benilla_world::frame_pace`). Logging both side by
// side is the A/B: paced, `vdt` holds the cadence while `dt` still reports what really happened.
// The clock the pose advances on (`benilla_world::frame_pace`), logged beside the wall `dt`.
vtime: Res<Time<bevy::time::Virtual>>,
) {
let now = time.elapsed_secs();
@@ -270,12 +200,9 @@ fn sample_jitter(
return;
};
let cam_pos = cam_g.translation();
// Nearest match to the camera — with no substring, nearest rigged unit outright.
// The match nearest the camera.
let want = meter.want.clone();
// `WOW_JITTER="self"` aims the meter at the AVATAR — the director's own repro (an undressed
// character, watched from a close third person), and the one subject every other spelling of
// this filter excluded. Without it the meter could only ever answer for somebody else's body,
// which is a different entity, a different equipment lane, and a camera that is seated ON it.
// `WOW_JITTER="self"` meters the avatar; every other filter excludes it.
let on_self = want == "self";
let Some((entity, guid, net, tf, rig, skin, _)) = subjects
.iter()
@@ -304,7 +231,7 @@ fn sample_jitter(
_ => return,
};
let px_per_yd = (window.physical_height() as f32 * 0.5) / (dist * (fovy * 0.5).tan());
// The rig composes in MODEL space; the root's scale is what carries it onto the screen.
// The rig composes in model space; the root's scale carries it into the world.
let scale = tf.scale().max_element();
let bones: Vec<Vec3> = rig
@@ -316,14 +243,10 @@ fn sample_jitter(
meter.radii = flesh_radii(&rig.parents, &rig.locals);
}
let flesh = flesh_probes(&rig.model, &meter.radii);
// The rows the vertex stage blends, probed the same way — the body's OWN ground truth. Empty
// when the subject holds no palette slot (bind-pose fallback), which reads as all-zero columns
// rather than as a clean result.
// The palette rows, probed the same way. Empty without a palette slot (bind-pose fallback),
// which prints as all-zero columns, not a clean result.
let mut bone_pos: Vec<Vec3> = Vec::new();
// The same rows' ROTATION, as the three transformed unit axes. Bone translations can be
// smooth while the frames they carry are not — a probe offset from the bone rides the
// rotation, and that is what the flesh/vertex actually follows. Differencing a unit axis
// measures the rotational motion at unit radius, directly comparable to the file's.
// The same rows' rotation, as the three transformed unit axes.
let mut bone_axes: Vec<[Vec3; 3]> = Vec::new();
let pal: Vec<Vec3> = match skin.and_then(|s| {
Some((
@@ -340,9 +263,7 @@ fn sample_jitter(
.collect();
}
let probes = palette_probes(&rows, &meter.binds, &meter.radii);
// The bones' OWN rig-relative positions (the row applied to the bind point, no probe
// offset) — the raw lane's series, and the one shape an offline compose of the same
// M2 can be differenced against point-for-point.
// Rig-relative bone positions (row applied to the bind point): the raw lane's series.
bone_pos = rows
.iter()
.zip(&meter.binds)
@@ -362,10 +283,8 @@ fn sample_jitter(
}
None => Vec::new(),
};
// **Project them.** The mirror of `wow_model.wgsl`'s vertex stage, operation for operation:
// camera-relative first (`p_rig + (origin - cam)`, never the absolute world coordinate), then
// the view rotation, then clip, then viewport. What comes out is where the eye finds that bit
// of flesh, in pixels, this frame.
// Project them as `wow_model.wgsl`'s vertex stage does: camera-relative first
// (`p_rig + (origin - cam)`), then view rotation, clip and viewport.
let vp = Vec2::new(
window.physical_width() as f32,
window.physical_height() as f32,
@@ -390,15 +309,13 @@ fn sample_jitter(
None => Vec::new(),
};
let camrot = cam_g.rotation();
// The anchors are ordinary scene-graph entities in ABSOLUTE world space — the seam 0974
// named and did not close. Their translations are read exactly as the renderer reads them.
// Anchors are scene-graph entities in absolute world space, read as the renderer does.
let ancs: Vec<Vec3> = rig
.anchors
.iter()
.map(|&(_, e)| globals.get(e).map_or(Vec3::ZERO, |g| g.translation()))
.collect();
// The rider frames of every attached model hanging off THIS unit, in host-rig space. Their
// origins are reported too — a rider is only exact while its origin holds still.
// The rider frames of every model attached to this unit, in host-rig space, with origins.
let rids: Vec<(Vec3, Vec3)> = riders
.iter()
.filter(|r| r.host == entity)
@@ -406,7 +323,7 @@ fn sample_jitter(
.collect();
let dt = time.delta_secs().max(1.0e-6);
// A new subject (or a bone count that changed under us — a re-skin) restarts the series.
// A new subject or a changed bone count (a re-skin) restarts the series.
if meter.subject != Some(entity)
|| meter.prev.len() != bones.len()
|| meter.prev_flesh.len() != flesh.len()
@@ -430,8 +347,7 @@ fn sample_jitter(
net.display_id,
bones.len(),
);
// Name the anchors once: bone id, and how much GEOMETRY hangs under each — the number
// that says whether a jittering anchor is a held weapon or a bookkeeping node.
// Name the anchors once: bone id and how many meshes hang under each.
let mut roster = Vec::new();
for (i, &(bone, e)) in rig.anchors.iter().enumerate() {
let mut n = 0usize;
@@ -508,7 +424,7 @@ fn sample_jitter(
return;
}
// Δ and Δ² per bone, in model space; the largest bone is the one the eye is reading.
// Δ and Δ² per bone, in model space, at the worst bone.
let (mut d2, mut worst) = (0.0f32, 0usize);
for (i, &p) in bones.iter().enumerate() {
let c = (p - 2.0 * meter.prev[i] + meter.prev2[i]).length();
@@ -518,10 +434,9 @@ fn sample_jitter(
}
}
let bd1 = (bones[worst] - meter.prev[worst]).length();
// The same two differences on the FLESH probes — rotation included. This is the number that
// has to be small for the model to read as still; `bd*` above is only its translation half.
// The same on the flesh probes, rotation included.
let (mut fd2, mut fworst) = (0.0f32, 0usize);
// Per-BONE, max over its three probes — so the spread below counts bones, not probe points.
// Per bone, max over its three probes, so the spread below counts bones.
let mut per_bone = vec![0.0f32; flesh.len() / 3];
for (i, &p) in flesh.iter().enumerate() {
let c = (p - 2.0 * meter.prev_flesh[i] + meter.prev2_flesh[i]).length();
@@ -533,17 +448,12 @@ fn sample_jitter(
}
}
let fd1 = (flesh[fworst] - meter.prev_flesh[fworst]).length();
// **Is it the model, or is it one bone?** The worst bone alone cannot tell those apart, and
// that is the whole question a report of "the WHOLE model shakes" asks. `fd2med` is the
// median bone's curvature and `fnbig` the count above a quarter-pixel: one twitching bone
// leaves the median at zero and the count at 1, while a model-wide shimmer lifts both.
// One bone or the whole model: `fd2med` is the median bone's Δ², `fnbig` the count past a
// quarter pixel; a single twitching bone leaves the median at zero.
let mut sorted = per_bone.clone();
sorted.sort_by(f32::total_cmp);
let fd2med = sorted.get(sorted.len() / 2).copied().unwrap_or(0.0);
// The matching Δ median. `fd1`/`fd2` above are read at whichever bone is worst THIS frame, so
// they are not one series and cannot be differenced against each other; these two are. Δ going
// to zero on alternate frames is a hold-then-jump stair-step; Δ steady while Δ² alternates is
// not, and the pair is what tells those apart.
// The matching Δ median; unlike the worst-bone columns, the medians form one series.
let mut d1s: Vec<f32> = (0..per_bone.len())
.map(|b| {
(0..3)
@@ -555,8 +465,7 @@ fn sample_jitter(
let fd1med = d1s.get(d1s.len() / 2).copied().unwrap_or(0.0);
let big_px = 0.25 / (scale * px_per_yd).max(1.0e-6);
let fnbig = per_bone.iter().filter(|&&c| c > big_px).count();
// The same pair on the PALETTE probes. No `scale` here, unlike the flesh columns: these rows
// are composed through the root's own frame, so the unit's scale is already in them.
// The same on the palette probes, without `scale`: the rows already carry the root's frame.
let (mut pd2, mut pworst) = (0.0f32, 0usize);
let mut pal_bone = vec![0.0f32; pal.len() / 3];
for (i, &p) in pal.iter().enumerate() {
@@ -575,9 +484,8 @@ fn sample_jitter(
.iter()
.filter(|&&c| c > 0.25 / px_per_yd.max(1.0e-6))
.count();
// **In pixels, at the glass.** Per bone, the max over its three probes; then the MEDIAN bone
// (the "whole model" number the report actually asks about), the worst bone, and the count
// past a quarter pixel.
// On screen, in pixels: per bone the max of its three probes, then the median bone, the worst
// and the count past a quarter pixel.
let mut scr_bone = vec![(0.0f32, 0.0f32); scr.len() / 3];
for (i, &q) in scr.iter().enumerate() {
let c = (q - 2.0 * meter.prev_scr[i] + meter.prev2_scr[i]).length();
@@ -593,20 +501,15 @@ fn sample_jitter(
let sd2med = s2.get(s2.len() / 2).copied().unwrap_or(0.0);
let sd2max = s2.last().copied().unwrap_or(0.0);
let snbig = s2.iter().filter(|&&c| c > 0.25).count();
// Camera ROTATION, in microradians: Δ and Δ² of the angle between consecutive orientations.
// Camera rotation in microradians: Δ and Δ² of the angle between consecutive orientations.
let ang = |a: Quat, b: Quat| a.angle_between(b) * 1.0e6;
let crd1 = ang(camrot, meter.prev_camrot);
let crd2 = (ang(camrot, meter.prev_camrot) - ang(meter.prev_camrot, meter.prev2_camrot)).abs();
// The flesh probe's **velocity**, mm/s — `Δ/dt`, not `Δ`. This is the judder discriminator,
// and it is the one number the series cannot reconstruct from the others: if the pose is
// smooth in time but sampled at uneven `dt`, then `Δ ≈ v·dt` is ragged exactly as `dt` is
// while `v` itself is clean, so `Δ²` reads large (`≈ v·Δ(dt)`) purely from the timing. A pose
// that is genuinely noisy has no such alibi: its velocity is ragged too. Differencing THIS
// column is what tells the two apart, and differencing `Δ` alone never can.
// The flesh velocity `Δ/dt`: clean when a smooth pose is sampled at uneven `dt`, ragged when
// the pose itself is noisy.
let fvel = fd1 * scale / dt;
// Same two differences on the anchors, and the WORST AXIS of the worst one: the f32 grid an
// absolute coordinate lands on is per-axis, so at ~9.5 k yards on one axis and ~60 on another
// the staircase is one-dimensional — which is the fingerprint, not an aside.
// The same on the anchors, plus the worst one's per-axis step: f32 precision of an absolute
// coordinate is per axis, so a large coordinate steps on one axis only.
let (mut ad2, mut aworst) = (0.0f32, 0usize);
for (i, &p) in ancs.iter().enumerate() {
let c = (p - 2.0 * meter.prev_anc[i] + meter.prev2_anc[i]).length();
@@ -620,7 +523,7 @@ fn sample_jitter(
} else {
(ancs[aworst] - meter.prev_anc[aworst]).length()
};
// The same two differences on the RIDER rows — the number the fix has to move.
// The same on the rider rows.
let (mut rd2, mut rworst) = (0.0f32, 0usize);
for (i, &(_, t)) in rids.iter().enumerate() {
let c = (t - 2.0 * meter.prev_rid[i] + meter.prev2_rid[i]).length();
@@ -639,8 +542,7 @@ fn sample_jitter(
} else {
(ancs[aworst] - meter.prev_anc[aworst]).abs()
};
// The animation clock, per playing node: how far the POSE's own time advanced this frame, and
// whether the set of playing animations changed under us (a replay/stop_all snaps the pose).
// The animation clock per playing node; a changed node set (replay, `stop_all`) snaps the pose.
let anim: Vec<(usize, f32, f32)> = players
.get(entity)
.map(|p| {
@@ -652,8 +554,7 @@ fn sample_jitter(
v
})
.unwrap_or_default();
// Δseek on the node we also had last frame; `restart` marks a node set that changed (or a seek
// that went backwards by more than a loop's worth of frame) — the pose-snap fingerprint.
// The largest Δseek across nodes; NaN when the node set changed.
let same = anim.len() == meter.prev_anim.len()
&& anim.iter().zip(&meter.prev_anim).all(|(a, b)| a.0 == b.0);
let dseek = if same {
@@ -668,23 +569,12 @@ fn sample_jitter(
let cam_d2 = (cam_pos - 2.0 * meter.prev_cam + meter.prev2_cam).length();
let root_d1 = (root - meter.prev_root).length();
let root_d2 = (root - 2.0 * meter.prev_root + meter.prev2_root).length();
// Δ² over dt² is the velocity change the frame actually applied — the term that stays put
// when a smooth pose is merely sampled at uneven times, and blows up when it is not smooth.
// Δ²/dt²: the velocity change the frame applied.
let dvel = d2 * scale / (dt * dt);
let mm = |v: f32| v * scale * 1000.0;
// **`WOW_JITTER_RAW=<bone>` — one bone's series, unaggregated.** Every other column here is a
// median or a worst-of over ~350 probe points, and both of those hide the shape of a series:
// a median cannot show a staircase (the *set* stays busy while each member holds), and a
// worst-of is not one series at all — it re-picks its subject every frame, so differencing it
// is meaningless. Twice now a reading taken off those columns pointed at the wrong mechanism.
// This prints the chosen bone's palette-probe position (rig-relative yards, full f32) and its
// projected screen position (pixels), so the series can be differenced honestly downstream.
// **The two ABSOLUTE numbers the vertex stage still sums** (`wow_model.wgsl`:
// `p_cam = frame_from_local·v + (frame_origin - view.world_position)`). Everything else on the
// path is rig-sized and exact; these two are world coordinates, so at Elwynn's ~9.5 k yards
// ONE of their axes has an ULP of 2^-10 yd = 0.9766 mm while the other two sit at ~7.6e-6.
// That asymmetry is the whole fingerprint: the staircase is one-dimensional, so it is
// invisible looking ALONG the big axis and full-amplitude looking across it.
// `ORIGIN`: the two absolute terms the vertex stage sums (`wow_model.wgsl`:
// `p_cam = frame_from_local·v + (frame_origin - view.world_position)`); at ~9.5 k yards one
// axis has an f32 ULP of 2^-10 yd.
if let Some(o) = rig_origin {
println!(
"ORIGIN\t{now:.6}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}",
@@ -692,9 +582,7 @@ fn sample_jitter(
);
}
if raw_bone() == Some(usize::MAX) {
// `WOW_JITTER_RAW=all` — the whole skeleton, one line per bone per frame. The point is
// localisation: differenced against an offline compose of the same M2 it says WHICH bones
// carry motion the file does not, and a subtree answer names the pass that added it.
// `WOW_JITTER_RAW=all`: one line per bone per frame, position and axes.
for (b, p) in bone_pos.iter().enumerate() {
let a = bone_axes.get(b).copied().unwrap_or_default();
println!(
@@ -716,6 +604,7 @@ fn sample_jitter(
);
}
} else if let Some(b) = raw_bone() {
// `WOW_JITTER_RAW=<bone>`: one bone's unaggregated series, rig position and screen pixel.
if let (Some(p), Some(q)) = (bone_pos.get(b), scr.get(3 * b)) {
println!(
"RAW\t{now:.6}\t{b}\t{:.9}\t{:.9}\t{:.9}\t{:.6}\t{:.6}\t{:.6}",
@@ -799,8 +688,7 @@ fn sample_jitter(
meter.prev_anim = anim;
}
/// `WOW_JITTER_RAW`'s bone — read once. Absent (or unparseable) leaves the raw lane silent, which
/// is the default: it is a debugging firehose, one line per frame on top of the `JIT` line.
/// `WOW_JITTER_RAW`'s bone (`all` is every bone), read once; unset leaves the raw lane silent.
fn raw_bone() -> Option<usize> {
static B: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
*B.get_or_init(|| {
@@ -1,46 +1,16 @@
//! The **transport census** (`WOW_LIFT_CENSUS=<secs>[,<every>]`) — the instrument that turns
//! *"the Thunder Bluff elevators aren't there"* into a line of numbers.
//! `WOW_LIFT_CENSUS=<secs>[,<every>]`: per type-11 elevator and type-15 boat, whether it
//! streamed, armed, built a model and where it is in its cycle. A transport spawns
//! [`Visibility::Hidden`] and its first ticked pose unhides it, so one that never arms is present,
//! solid and invisible.
//!
//! An absent lift is a screenshot, and a screenshot cannot separate the four ways a type-11
//! elevator (or a type-15 boat) can fail to appear — each of which calls for a different fix:
//! - `state`: `lift`/`taxi` (armed), `seed` (a type-11 waiting for its keyframe catalog), `bare`
//! (anchored, no drive), `parked` (no anchor).
//! - `vis`/`inh`: the root's [`Visibility`] and the propagated [`InheritedVisibility`]; `hidden=`
//! counts transports the tick never showed or judged off the live [`CurrentMap`] (`map=`).
//! - `meshes`: render descendants of the whole subtree.
//!
//! - the server never streamed it (**no entity** — nothing on the wire, our count is 0);
//! - it streamed but never **armed** (`state=seed`/`bare`): a transport spawns
//! [`Visibility::Hidden`] and is unhidden by its first ticked pose (decision 0438), so an arm
//! that never completes leaves a car that is present, solid, and permanently invisible —
//! B168's "an invisible wall in its place";
//! - it armed and ticks, but the model never built (`meshes=0`) — an asset gap;
//! - it armed, ticks and draws, and the car is simply somewhere else in its cycle
//! (`cycle=`/`pos=` say where, against the authored keyframes).
//!
//! ```text
//! LIFT 0xF11000000000504B entry=4170 disp=360 type=11 state=lift period=30033 cycle=17421
//! moving=0 vis=Inherited inh=1 meshes=4 attached=1 pos=(-1286.24,189.72,130.08) d=9.4
//! ```
//!
//! - **`state`** is the arm verdict read off the components themselves: `lift`/`taxi` (armed),
//! `seed` (a type-11 waiting for its keyframe catalog), `bare` (an anchored transport with no
//! drive — the boat arm waiting on a template, or a type-11 whose create carried no entry),
//! `parked` (no anchor: a pathless type-11 the arm deliberately released, or a type the wire
//! never flagged).
//! - **`vis`/`inh`** are the root's own [`Visibility`] and the propagated
//! [`InheritedVisibility`]. `vis=Hidden` on a transport is the finding: either the tick never ran
//! for it, or it ran and judged the car off-map. The headline `hidden=` count is that number, and
//! `map=` beside it is the live [`CurrentMap`] the tick judged against — the pair that named
//! decision 1654 (`hidden=11 … map=1`, every lift on Kalimdor, all of them armed and ticking).
//! - **`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 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 \
//! WOW_PROBE_CHAT=".go xyz -1286.2 189.7 132.0 1" \
//! WOW_LIFT_CENSUS="30,15" WOW_PROBE_EXIT_AT=75 cargo run -q -p benilla
//! ```
//!
//! The repeat form is the load-race half: a census at 30 s and again at 45 s says whether a
//! `state=seed` is a transient (the catalog had not opened yet) or the verdict.
//! The repeat form tells a transient `state=seed` from a stuck one. How to run it:
//! `docs/CONTRIBUTING.md`, "Running it unattended".
use benilla_assets::coords::bevy_to_wow;
use benilla_protocol::EntityKind;
@@ -53,8 +23,7 @@ use crate::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
use crate::transport::{ElevatorSeed, Transport, TransportAnchor};
use benilla_world::world_map::CurrentMap;
/// How deep the render-descendant walk goes. A GO's submeshes sit at most a couple of levels
/// under the net root (root → anim host → submesh); eight is slack, not a limit anything reaches.
/// Depth of the render-descendant walk; a GO's submeshes sit two levels down (root, anim host).
const WALK_DEPTH: u32 = 8;
pub(crate) struct LiftCensusPlugin;
@@ -72,15 +41,14 @@ impl Plugin for LiftCensusPlugin {
}
}
/// [`LiftCensusPlugin`] state: when the next census fires, and how often after that (`0` = once).
/// [`LiftCensusPlugin`] state; `every` of 0 fires once.
#[derive(Resource)]
struct LiftCensus {
next: f32,
every: f32,
}
/// What the census reads per entity: identity, kind/display, pose, the three transport
/// components (each one a distinct arm stage), the visibility pair, and the visual gate.
/// What the census reads per entity; each transport component is a distinct arm stage.
type CensusQuery = (
&'static Guid,
&'static NetEntity,
@@ -95,11 +63,8 @@ type CensusQuery = (
Has<VisualAttached>,
);
/// One line per streamed transport GameObject — hidden ones first, then by distance — under a
/// summary line naming the counts that matter. No radius: vmangos sends a map's **whole** transport
/// set at world entry and keeps it resident (`Map::SendInitTransports`, `Map.cpp:1719`; every
/// type-11 joins `m_transports` at `Map::Add`) — decision 0678 measured the same thing from a
/// ship's lanterns at 4853 yd. So the population is a handful and all of it is in reach.
/// One line per streamed transport, hidden first, then by distance. No radius: vmangos sends a
/// map's whole transport set at world entry (`Map::SendInitTransports`, `Map.cpp:1718`).
fn fire_lift_census(
mut probe: ResMut<LiftCensus>,
time: ProbeClock,
@@ -125,9 +90,8 @@ 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 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.
// The GO types the reference's per-frame tick `0x630970` fires, plus anything carrying a
// transport component, so a misread type field still lists.
if !(net.kind == EntityKind::GameObject && matches!(go_type, 11 | 15)
|| transport.is_some()
|| anchor.is_some()
@@ -199,13 +163,12 @@ fn fire_lift_census(
}
}
/// A borrowed child slice, or an empty one — the `Option<&Children>` unwrap the walk starts from.
/// The children to start the walk from, or none.
fn entity_children(kids: Option<&Children>) -> Vec<Entity> {
kids.map(|c| c.iter().collect()).unwrap_or_default()
}
/// Count the render descendants of a subtree — the whole subtree, because a GameObject's
/// submeshes hang under an anim host rather than directly off the net root.
/// Counts render descendants of the whole subtree; a GO's submeshes hang under an anim host.
fn render_descendants(
roots: Vec<Entity>,
children: &Query<&Children>,
+70 -169
View File
@@ -1,14 +1,6 @@
//! The live frame-time sample ([`LiveFpsPlugin`]) and everything printed from the same sample
//! window: the `FPS_PROBE` line itself, the visible-submesh census
//! (`VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`), the asset-churn ratchet (`MAT_CHURN`) and the
//! residency meter (`ASSET_DUMP`). One file because one system, `drive_live_fps`, prints all four
//! from the same frame at the same instant — they are one measurement with four line families,
//! not four instruments.
//!
//! **What is counted lives in [`benilla_world::world_census`]; what is printed lives here.** The census
//! is the engine's own published account of the frame it drew (decision 1164) — this probe adds
//! the timing window around it and owns the line shapes, which are a greppable contract of ours
//! and no business of the renderer's.
//! The live frame-time sample ([`LiveFpsPlugin`]) and the lines printed from the same window:
//! `FPS_PROBE`, `VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`, `MAT_CHURN` and `ASSET_DUMP`. The counting
//! is [`benilla_world::world_census`]'s; the timing window and the line shapes are here.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
@@ -16,22 +8,13 @@ use bevy::prelude::*;
use crate::capture::PROBE_WARMUP_FRAMES;
use benilla_world::world_census::WorldCensus;
/// The LIVE FPS probe (`WOW_LIVE_FPS=<frames>`, delay via `WOW_LIVE_FPS_AT` seconds, default 25;
/// `WOW_LIVE_FPS_MOVE=1` holds W through warmup + sampling, so the probe measures RUNNING through
/// the scene — streaming, spawns, re-classification — not a parked camera; the 0366 hunt's
/// "running around SW" gap):
/// the [`crate::capture::CapturePlugin`] probe's numbers on a NORMAL connected run — streamed units, net
/// apply, quest markers, everything the server-less harness deliberately excludes. Built for the
/// 0362 residual: the serverless stormwind probe pinned 60 while the director's live session read
/// 20, so the gap IS the live world — this instrument measures it. Waits for in-world + the delay
/// (park the character first with [`super::ProbeChatPlugin`]), uncaps vsync, warms
/// [`PROBE_WARMUP_FRAMES`], samples, prints the same machine-greppable `FPS_PROBE` line
/// (scenario=`live`), and exits.
/// `WOW_LIVE_FPS=<frames>` (default 300): the [`crate::capture::CapturePlugin`] probe's numbers on
/// a connected run. Waits for the world and `WOW_LIVE_FPS_AT` seconds (default 25), uncaps vsync,
/// warms [`PROBE_WARMUP_FRAMES`], samples, prints `FPS_PROBE scenario=live` and exits.
/// `WOW_LIVE_FPS_MOVE=1` holds W throughout, measuring a run through the scene.
pub(crate) struct LiveFpsPlugin;
/// How long past `WOW_LIVE_FPS_AT` the probe waits for a world before declaring the run dead.
/// Entry normally lands well inside `at` itself; 60 s of grace covers a cold-cache load without
/// ever letting a stranded run ride out a harness timeout (see the `Waiting` arm).
/// Seconds past `WOW_LIVE_FPS_AT` without a world before the run exits as dead; covers a cold load.
const BOOT_DEADLINE_SECS: f32 = 60.0;
impl Plugin for LiveFpsPlugin {
@@ -67,8 +50,7 @@ impl Plugin for LiveFpsPlugin {
})
.add_systems(Update, drive_live_fps);
WorldCensus::churn_counters(app);
// The engine counts its own materials; the UI pass is ours, so we fold it into the same
// tally rather than keeping a second one that could disagree about the window.
// The UI pass's material joins the engine's churn tally.
WorldCensus::count_churn::<crate::ui_pass::UiQuadMaterial>(app, "uiquad");
}
}
@@ -81,7 +63,6 @@ enum LiveFpsPhase {
Done,
}
/// [`LiveFpsPlugin`] state.
/// The still-frame input counters (`benilla_world::dev_state::STILL_INPUTS_CHANGED`), now.
fn still_inputs_now() -> [u32; 7] {
let c = &benilla_world::dev_state::STILL_INPUTS_CHANGED;
@@ -92,84 +73,56 @@ fn still_inputs_now() -> [u32; 7] {
struct LiveFps {
frames: usize,
at: f32,
/// Hold W while measuring (`WOW_LIVE_FPS_MOVE=1`) — the moving-workload probe.
/// Hold W while measuring (`WOW_LIVE_FPS_MOVE=1`).
run: bool,
phase: LiveFpsPhase,
samples: Vec<f32>,
/// Process CPU per frame, ms, index-parallel with `samples` — the TAIL half of `cpu_ms`:
/// a mean under budget hides the one frame in twenty that is over it, and whether that
/// frame was CPU work or a wait is the first question about a stutter.
/// Process CPU per frame, ms, index-parallel with `samples`: says whether a slow frame was
/// work or a wait.
cpu_samples: Vec<f32>,
cpu_prev: Option<f64>,
/// Per frame, beside `cpu_samples`: entities added net of removed, mesh assets added,
/// model materials added — the three spike signatures a crowd produces (a spell kit
/// spawning, a body dressing, a first-seen material realizing). Printed for the worst
/// frames only.
/// Per frame: net entities added, meshes added, model materials added, sound kit decodes.
/// Printed for the worst frames only.
churn_samples: Vec<(i32, u32, u32, u32)>,
entities_prev: usize,
decodes_prev: u32,
/// Live particles and the GPU meter's freshest reading per frame — the two "same work,
/// more of it" signatures the churn triple cannot carry.
/// Live particles and the GPU meter's freshest reading per frame.
load_samples: Vec<(u32, f32)>,
/// The PACED clock's delta per frame (`Time<Virtual>`, `frame_pace`): under vsync with
/// pipelined rendering the raw delta is mis-split into long/short pairs that sum to exact
/// refresh multiples, so `samples`' tail counts presentation jitter as frames. The pacer
/// snaps a cadenced delta to whole refreshes; a paced delta of two periods is a frame the
/// display actually skipped.
/// The paced delta per frame (`Time<Virtual>`, `frame_pace`). Under vsync with pipelined
/// rendering the raw delta splits into long/short pairs; a paced delta of two refresh periods
/// is a frame the display skipped.
paced_samples: Vec<f32>,
/// Per-thread CPU at the window's first frame (`perf::thread_cpu_table`), keyed by pthread
/// identity — subtracted at the window's end for `thr=[name:ms/frame,…]`.
/// Per-thread CPU at the window's first frame (`perf::thread_cpu_table`), keyed by pthread,
/// the baseline for `thr=[name:ms/frame,…]`.
threads_at_start: Option<Vec<(usize, String, f64, f64)>>,
/// The page-fault counters at the window's first frame (`perf::process_faults`).
faults_at_start: Option<(u64, u64)>,
/// `dev_state::STILL_INPUTS_CHANGED` at the window's first frame — `noisy=` is the delta.
/// `dev_state::STILL_INPUTS_CHANGED` at the window's first frame; `noisy=` is the delta.
still_at_start: [u32; 7],
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the
/// baseline for the window's `cpu_ms`/`cpu_pct`.
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]), the
/// baseline for `cpu_ms`/`cpu_pct`.
cpu_at_start: Option<f64>,
/// Machine-wide CPU ticks at the first sampled frame ([`crate::perf::system_cpu_ticks`]) — the
/// baseline for `sys_busy_pct`, which says whether anyone ELSE was competing for the cores
/// while this leg ran. `cpu_ms` alone cannot: see that function's header for the 25.93-vs-18.8
/// leg that motivated it (1157).
/// Machine-wide CPU ticks at the first sampled frame ([`crate::perf::system_cpu_ticks`]), the
/// baseline for `sys_busy_pct`: whether other processes competed for the cores.
sys_at_start: Option<(u64, u64)>,
/// The window's occlusion state, maintained from `WindowOccluded` transitions. macOS
/// throttles a FULLY covered window to ~1 fps (any covering window, not just the lock
/// screen — the director's correction to 0729/0730's lock-screen reading): a probe launched
/// detached spawns unfocused and can land completely behind other windows, and its leg then
/// measures the throttle, not the client.
/// Occlusion, from `WindowOccluded`: macOS throttles a fully covered window to ~1 fps.
occluded_now: bool,
/// Sampled frames taken while occluded — `occluded_frames=` on the probe line, so a
/// throttled leg names itself instead of being inferred from a ~1 s hitch signature.
/// Sampled frames taken while occluded (`occluded_frames=`).
occluded_frames: usize,
}
/// **Where** the sample was taken — the 0705 prove-the-run law: a probe number is evidence only
/// once the body is known to be at the pin, and `WOW_PROBE_CHAT`'s `.go` can silently fail (a bad
/// map id, a refused command) leaving the run measuring the login spot.
///
/// The *scene-state* half of the pin — which room the camera claims, how many exterior windows,
/// what the cull did — is the engine's, and arrives with the rest of the census. Without it a
/// `drawn=` reading taken indoors cannot be read at all: a big number means either "we claimed a
/// room and the cull let everything through" or "we never claimed a room, so nothing was gated" —
/// opposite bugs with identical numbers, and the difference cost a measurement (0780).
/// The display stamp's monitor roster + the `WOW_GPU_MS=1` meter + its sample sink, bundled
/// (the 16-SystemParam ceiling, the house's SpawnTables shape).
/// The rest of [`drive_live_fps`]'s params, bundled under the 16-SystemParam ceiling.
type ScreenParams<'w, 's> = (
Query<'w, 's, &'static bevy::window::Monitor>,
Option<Res<'w, crate::perf::GpuMsShared>>,
Local<'s, Vec<f32>>,
Option<Res<'w, crate::perf::WgpuCensusShared>>,
// Every camera, for `views=`: the number of ACTIVE views the render app walked this frame —
// each is its own pass set (a portrait booth awake, a body pane open), and a lane that
// scales with views (cluster assignment, the view bind groups, every per-pass tracker) is
// not a scene cost until this number is known.
// Every camera, for `views=`: active views, each its own pass set.
Query<'w, 's, &'static Camera>,
// The UI's live texture-identity runs (`ui_batches=`): one `Mesh2d` draw each in the 2D pass.
Query<'w, 's, (), With<crate::ui_pass::UiQuadBatch>>,
// Every view-visible `Mesh3d` by lane (`vis_terrain/model/liquid/other=`): the 3D passes'
// draw population. `drawn=` counts model submeshes only; the terrain is one entity per
// ADT chunk and a city horizon is over a thousand of them — a per-draw cost is not
// attributable until this split is known.
// Every view-visible `Mesh3d` by lane (`vis_terrain/model/liquid/other=`); `drawn=` counts
// model submeshes only, and terrain is one entity per ADT chunk.
Query<
'w,
's,
@@ -186,8 +139,8 @@ type ScreenParams<'w, 's> = (
MessageReader<'w, 's, AssetEvent<benilla_assets::materials::WowModelMaterial>>,
Res<'w, Time<bevy::time::Virtual>>,
ResMut<'w, crate::perf::MainThreadSplit>,
// The additive blend-state check (`perf::blend_check`): draws whose bound blend state
// contradicted their material this frame — `blend_mismatch=` on the line.
// `blend_mismatch=` (`perf::blend_check`): draws whose bound blend state contradicts their
// material.
Option<Res<'w, crate::perf::BlendMismatchShared>>,
);
@@ -195,40 +148,31 @@ type ScreenParams<'w, 's> = (
struct SamplePin<'w, 's> {
map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
body: Option<Res<'w, crate::player::Player>>,
/// The eye the leg was measured through — pose-stamped like `sys_busy_pct` time-stamps load,
/// and for the same reason: two legs are only comparable if this matches. 1475's bring-up
/// lost an hour to a probe whose camera sat first-person pitched 26° down — a per-character
/// saved camera file, invisible in every count — and the census read as a regression hunt.
/// The camera the leg was measured through: two legs compare only when it matches.
cam: Query<'w, 's, &'static GlobalTransform, With<benilla_world::view::WorldCamera>>,
}
/// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the
/// harness probe's `Phase::ProbeWarmup`/`Probing` arms.
/// Waits for the world and the delay, uncaps, warms, samples, prints and exits: the live form of
/// the harness probe's `Phase::ProbeWarmup`/`Probing` arms.
fn drive_live_fps(
mut probe: ResMut<LiveFps>,
time: Res<Time<bevy::time::Real>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
// What the engine drew this frame and what it is holding — submeshes, the exterior-scene
// gate, emitters, resident assets, the churn window. One param, one instant.
// What the engine drew this frame and what it holds.
mut census: WorldCensus,
streamed: Query<(), With<crate::net::NetEntity>>,
// The animation-LOD gate's effect, machine-readable per probe (decision 0448): how many
// streamed rigs sat parked at sample end.
// Rigs the animation-LOD gate parked, at sample end.
parked: Query<(), With<benilla_world::rig_anim::AnimParked>>,
entities: Query<()>,
pin: SamplePin,
// The owned skin-palette occupancy (decision 0720) — `rigs=live/peak bones=live/peak` on the
// probe line proves the palette lane is actually populated (an all-zero table renders
// origin-collapsed rigs, which no other probe number would catch).
// Skin-palette occupancy (`rigs=live/peak`); an empty table renders origin-collapsed rigs.
palettes: Option<Res<benilla_world::rig_palette::RigPalettes>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut key_events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
mut exit: MessageWriter<AppExit>,
mut occlusions: MessageReader<bevy::window::WindowOccluded>,
// Bundled (the 16-SystemParam ceiling): the monitor roster for the display stamp, and the
// `WOW_GPU_MS=1` meter — the render app's whole-frame GPU clock, sampled per probe frame so
// the leg line carries gpu percentiles beside the cpu ones (absent when the meter is off).
// Includes the `WOW_GPU_MS=1` meter, the render app's whole-frame GPU clock.
mut screen: ScreenParams,
) {
let (monitors, gpu, gpu_samples, wgpu_census, cameras, ui_batches, vis_meshes) = (
@@ -247,8 +191,7 @@ fn drive_live_fps(
.11
.as_ref()
.map_or(0, |m| m.0.load(std::sync::atomic::Ordering::Relaxed));
// Read every frame (a reader that only reads inside the window would report the whole
// backlog on its first sampled frame).
// Read every frame, or the first sampled frame would report the whole backlog.
let mesh_added = mesh_events
.read()
.filter(|e| matches!(e, AssetEvent::Added { .. }))
@@ -257,22 +200,16 @@ fn drive_live_fps(
.read()
.filter(|e| matches!(e, AssetEvent::Added { .. }))
.count() as u32;
// Drain every frame so the state is current whichever phase we're in — the window can be
// occluded before sampling ever starts (a detached launch spawns behind whatever is open).
// Drained every frame: the window can be occluded before sampling starts.
for o in occlusions.read() {
probe.occluded_now = o.occluded;
}
match probe.phase {
LiveFpsPhase::Done => {}
LiveFpsPhase::Waiting => {
// (The un-occludable window that keeps the SETTLE phase from streaming the world at
// ~1 fps is [`ProbeFocusPlugin`]'s now — every probe needs it, not just this one.)
if time.elapsed_secs() < probe.at || self_player.is_empty() {
// The boot deadline: entering the world takes seconds, and `at` already grants a
// 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
// a leg runner keys on.
// Still worldless past the deadline: stranded on a glue screen. A leg runner
// keys on `FATAL`.
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",
@@ -291,10 +228,8 @@ fn drive_live_fps(
if probe.run { ", holding W" } else { "" }
);
if probe.run {
// A synthetic held key: `ButtonInput` persists a press until its release, and the
// winit feed only releases keys it saw go down, so this holds across frames. The
// raw KeyboardInput message rides along for the binding dispatch's press edge
// (0997 — MOVEFORWARD latches off the event, holds off the state).
// Held until released: winit only releases keys it saw go down. The raw message
// gives the binding dispatch its press edge (MOVEFORWARD latches off the event).
keys.press(KeyCode::KeyW);
key_events.write(bevy::input::keyboard::KeyboardInput {
key_code: KeyCode::KeyW,
@@ -324,8 +259,7 @@ fn drive_live_fps(
probe.still_at_start = still_inputs_now();
main_split.restart();
probe.sys_at_start = crate::perf::system_cpu_ticks();
// The churn census restarts with the window — warmup noise (streaming, shader
// warms) would otherwise read as steady-state ratchets.
// Restart churn with the window so warmup does not read as a ratchet.
census.restart_churn();
probe.occluded_frames = 0;
}
@@ -378,19 +312,13 @@ fn drive_live_fps(
.single()
.map(|w| (w.physical_width(), w.physical_height()))
.unwrap_or((0, 0));
// The present mode actually measured under — an uncap that silently rails (0362) is
// only diagnosable if the line says what was asked for.
// The present mode measured under.
let present = windows
.single()
.map(|w| format!(" present={:?}", w.present_mode))
.unwrap_or_default();
// Which display the window sits on, and that display's refresh rate — the regime a
// railed leg is railed AT. Two legs of one sitting have read 126.5 vs exactly 60.0 fps
// under the same uncapped present mode (1388 round 3, 1395's pin), and nothing on the
// line said why: this machine drives a 60 Hz external beside a 120 Hz built-in, and
// present=AutoNoVsync resolves to Metal displaySync=false either way — the rail, when
// one appears, is the WindowServer's, keyed to the display. Stamp it so a regime split
// reads off the line instead of costing a discarded round.
// The display under the window's centre and its refresh rate: on macOS an uncapped
// leg can still rail at the WindowServer's per-display rate.
let display = {
let center = windows.single().ok().and_then(|w| match w.position {
bevy::window::WindowPosition::At(p) => {
@@ -423,15 +351,14 @@ fn drive_live_fps(
None => " display=unpositioned".to_string(),
}
};
// The GPU meter's percentiles over the same window (WOW_GPU_MS=1; empty otherwise).
// GPU percentiles over the same window (`WOW_GPU_MS=1`; empty otherwise).
let gpu_line = if gpu_samples.is_empty() {
String::new()
} else {
let mut g = std::mem::take(&mut **gpu_samples);
g.sort_by(f32::total_cmp);
let gat = |q: f32| g[(((g.len() - 1) as f32) * q).round() as usize];
// …and the wgpu resource census beside it (`perf::gpu::WgpuCensus`): the
// residency term of the pass-encode CPU, sampled at the window's end.
// The wgpu resource census (`perf::gpu::WgpuCensus`) at the window's end.
let census = wgpu_census
.as_ref()
.map(|c| {
@@ -461,16 +388,13 @@ fn drive_live_fps(
g[g.len() - 1]
)
};
// CPU cost per frame across every thread — the load-robust half of the measurement
// (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %.
// Process CPU per frame across every thread (`perf::process_cpu_secs`).
let v_len = v.len();
let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) {
(Some(t0), Some(t1)) => {
let per_frame_ms = (t1 - t0) * 1000.0 / v.len() as f64;
// The tail: CPU percentiles over the same frames, how many frames ran over
// the 60 Hz budget, and the worst six as `index:frame_ms/cpu_ms` — a frame
// whose cpu is near its length was WORK; one far under it was a WAIT (GPU,
// present, a lock).
// CPU percentiles, frames over the 60 Hz budget, and the worst six as
// `index:frame_ms/cpu_ms`: cpu near frame length was work, far under a wait.
let mut c: Vec<f32> = probe.cpu_samples.clone();
c.sort_by(f32::total_cmp);
let cat = |q: f32| c[(((c.len() - 1) as f32) * q).round() as usize];
@@ -500,8 +424,7 @@ fn drive_live_fps(
})
.collect::<Vec<_>>()
.join(",");
// Per thread, ms per frame over the window — the split that says which
// thread a tax landed on; threads sharing a name (a pool) are summed.
// Per thread, ms per frame; threads sharing a name (a pool) are summed.
let threads = match (&probe.threads_at_start, crate::perf::thread_cpu_table()) {
(Some(t0), Some(t1)) => {
let start: std::collections::HashMap<usize, (f64, f64)> =
@@ -538,9 +461,8 @@ fn drive_live_fps(
.zip(probe.still_at_start)
.map(|(n, s)| n.wrapping_sub(s))
.collect();
// Page faults per frame beside the split: `sys` time with no
// syscall under it is the kernel zero-filling pages the allocator
// gave back and asked for again.
// Page faults per frame: `sys` time without syscalls is the kernel
// zero-filling pages.
let faults =
match (probe.faults_at_start, crate::perf::process_faults()) {
(Some((mi0, ma0)), Some((mi1, ma1))) => format!(
@@ -571,11 +493,8 @@ fn drive_live_fps(
}
_ => String::new(),
};
// How busy the WHOLE machine was across the same window — every core, every process.
// `cpu_ms` is load-ROBUST, not load-immune (1157: the same pin leg read 25.93 with two
// other slots compiling and 18.80-20.12 quiet), so a leg that does not say this cannot
// be compared with one taken at a different time. A stamp, never a gate: legs at
// similar `sys_busy_pct` are comparable, legs far apart are not.
// Machine-wide busy share over the window: `cpu_ms` rises under outside load, so legs
// compare only at similar `sys_busy_pct`. A stamp, not a gate.
let sys = match (probe.sys_at_start, crate::perf::system_cpu_ticks()) {
(Some((b0, t0)), Some((b1, t1))) if t1 > t0 => {
format!(
@@ -585,9 +504,8 @@ fn drive_live_fps(
}
_ => String::new(),
};
// The pin the number belongs to, in the `.go xyz` order, so a probe line can be
// matched against the report's coordinates without a second instrument — followed by
// the exterior-scene gate's state, which is what makes `drawn=` legible indoors.
// Where the sample was taken, in `.go xyz` order: a failed `.go` leaves the body at
// login. Then the exterior-scene gate's state, which `drawn=` indoors needs.
let at_pin = match (pin.map.as_ref(), pin.body.as_ref().filter(|b| b.active)) {
(Some(m), Some(b)) => {
let [x, y, z] = benilla_assets::coords::bevy_to_wow(b.pos);
@@ -610,16 +528,13 @@ fn drive_live_fps(
(Some(room), Some(w)) => format!(" room={room} windows={w}"),
_ => String::new(),
};
// Beside `room=`, because the two answer the same question from opposite ends:
// `room=none` says the camera claims no interior, `sky=` says whether some building's
// PVS is painting its own backdrop over the world anyway.
// Whether a building's PVS paints its own backdrop, the counterpart of `room=`.
let sky = seen
.sky
.as_deref()
.map(|s| format!(" sky={s}"))
.unwrap_or_default();
// The effect stream's other half. `emitters=`/`particles=` above count quad clouds
// only, so a screen full of ribbon trails reads as an empty scene without this.
// `emitters=`/`particles=` count quad clouds only; ribbons are counted here.
let ribbons = seen
.ribbons
.map(|(n, d)| format!(" ribbons={n} ribbons_drawn={d}"))
@@ -680,8 +595,7 @@ fn drive_live_fps(
ui_batches.iter().count(),
vis = {
let mut n = [0usize; 4];
// Of the model parts, how many draw in the transparent pass (M2 `Blend`
// batches) — the sorted phase whose draws never batch across materials.
// Model parts in the transparent pass (M2 `Blend`), which never batch.
let mut blended = 0usize;
for (vv, terrain, model, liquid) in vis_meshes.iter() {
if !vv.get() {
@@ -730,8 +644,7 @@ fn drive_live_fps(
probe.occluded_frames,
);
print_vis_census(&seen);
// The window's Modified-event totals per asset type — a type at ~1×/frame here is a
// per-frame re-upload ratchet; absent means quiet.
// Modified-event totals per asset type; ~1×/frame is a per-frame re-upload.
if !seen.churn.is_empty() {
let churn = seen
.churn
@@ -760,20 +673,9 @@ fn drive_live_fps(
}
}
/// **What is on the screen, and who is accountable for it** — the `VIS_CENSUS` line beside
/// `FPS_PROBE`, plus a per-model breakdown under `WOW_VIS_DUMP=1`.
///
/// `drawn=` alone cannot answer "why can I still see that from in here?". It is one number over
/// every model submesh, and the interesting split is not visible-vs-not: it is which *subsystem* the
/// visible thing belongs to and whether anything is gating it at all. A tree that draws through a
/// wall is a completely different defect depending on whether it is tagged as exterior scene
/// (tagged but admitted — the cull or the bound is wrong) or is not (nothing is gating it — the
/// wrong lane spawned it). Naming which took a screenshot, an asset dig and a wrong guess; this
/// line answers it in one run.
///
/// `WOW_VIS_DUMP=1` then names the models: one `VIS_DUMP` line per distinct visible label, ungated
/// first, most-drawn first — which is the "so WHICH trees are they?" question. The counting is
/// [`benilla_world::world_census`]'s; the line shapes below are ours.
/// `VIS_CENSUS`: visible submeshes per kind and whether the exterior-scene gate covers them, so a
/// model drawing through a wall reads as tagged-but-admitted or ungated. `WOW_VIS_DUMP=1` adds one
/// `VIS_DUMP` line per visible label, ungated first.
fn print_vis_census(seen: &benilla_world::world_census::CensusReport) {
let line = seen
.kinds
@@ -798,8 +700,7 @@ fn print_vis_census(seen: &benilla_world::world_census::CensusReport) {
"VIS_CENSUS visible-submeshes {line} | resident {resident} | why {why} | tagged={} hidden={} exempt={} no_aabb={}",
seen.tagged, seen.hidden, seen.exempt, seen.no_aabb
);
// The escapees always print: a tagged, bounded object the cull left un-hidden is a defect by
// construction, and burying it behind a flag is how it stays unnoticed.
// Always printed: a tagged, bounded object the cull left visible is a defect.
for (label, card, n) in &seen.escaped {
let c = if *card { " BILLBOARD-CARD" } else { "" };
println!("VIS_ESCAPED {n}{c} {label}");
+27 -78
View File
@@ -1,15 +1,10 @@
//! The LIVE-run probe instruments — every plugin here rides a NORMAL connected session (unlike
//! the parent's server-less [`super::CapturePlugin`] harness): scripted chat sends
//! ([`ProbeChatPlugin`]), synthetic key taps ([`ProbeKeyPlugin`]), a Lua chunk in the live UI VM
//! ([`ProbeLuaPlugin`]), the bounded-lifetime self-exit ([`ProbeExitPlugin`]), and the live
//! frame-time sampler ([`LiveFpsPlugin`]). The live screenshot and its validity gates live in the
//! sibling [`super::live_shot`]. Each is env-gated and registered by `main`; compose them for
//! unattended "park, act, observe" probes.
//! The live-run probe instruments, riding a connected session (unlike the server-less
//! [`super::CapturePlugin`] harness). Each is env-gated and registered by `main`; the live
//! screenshot is [`super::live_shot`].
use bevy::prelude::*;
/// The **actuation** probes — the three channels that make a live session do something
/// headlessly: chat/slash lines, synthetic key presses, and a Lua chunk in the UI VM.
/// The particle-draw census (`WOW_FX_CENSUS=1`).
mod fx_draw_census;
pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin;
mod act;
@@ -17,106 +12,67 @@ pub(crate) use act::{
ProbeChatPlugin, ProbeDragPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
};
/// The probe **run** itself — its bounded lifetime, and the window it lives in (level,
/// parking, size). Nothing here measures anything; it is the shell every other probe rides.
/// The probe run's shell: its bounded lifetime and its window's level, parking and size.
mod run;
pub(crate) use run::{ProbeExitPlugin, ProbeFocusPlugin, ProbeResizePlugin};
/// The live frame-time sample — `FPS_PROBE` and the companion lines emitted from the same
/// sample window (`VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`, `MAT_CHURN`, `ASSET_DUMP`).
/// The live frame-time sample, `FPS_PROBE`, and the lines printed from the same window.
mod live_fps;
pub(crate) use live_fps::LiveFpsPlugin;
/// The particle census — per-emitter state plus the draw-distance accounting behind B39.
/// The particle census: per-emitter state and draw-distance accounting.
mod particle_census;
pub(crate) use particle_census::ParticleCensusPlugin;
/// The under-floor census — per-unit "where the server put it vs where we drew it", the instrument
/// that turns a screenshot of an NPC below a floor into a number (B197, decision 1384).
/// The under-floor census: per unit, where the server put it against where we drew it.
mod ground_census;
pub(crate) use ground_census::GroundCensusPlugin;
/// The transport census — per type-11/15 GameObject, the arm stage, the visibility pair and the
/// render-descendant count: the reader that separates a lift the server never sent from one that
/// never armed and so never lifted its spawn-hide (B168).
/// The transport census: per type-11/15 GameObject, its arm stage, visibility and mesh count.
mod lift_census;
pub(crate) use lift_census::LiftCensusPlugin;
mod trail_census;
pub(crate) use trail_census::TrailCensusPlugin;
/// The unit-visual census — per-entity "what visual did this display actually get", which is what
/// separates a debug cube (a gap of ours) from a model that legitimately draws nothing (B13,
/// decision 1403).
/// The unit-visual census: per entity, which visual its display got, telling a debug cube from a
/// model that draws nothing.
mod visual_census;
pub(crate) use visual_census::UnitVisualsPlugin;
/// The motion-jitter meter — per frame, the camera / root / pose terms of a subject's rendered
/// position as first *and second* differences, in mm and in pixels at its own distance: the
/// reader that separates "the pose is noisy" from "the pose is smooth and the frames are not".
/// The motion-jitter meter: per frame, the camera, root and pose terms of a subject's rendered
/// position as first and second differences.
mod jitter;
pub(crate) use jitter::JitterMeterPlugin;
/// The dress census — per-player "what did the wire ask for, what did we resolve, what is actually
/// hanging off the skeleton": the three things a screenshot of a geared character conflates, and
/// the reader that turns "my show-helm preference is ignored" into a counted contradiction (B123,
/// decision 1472).
/// The dress census: per player, what the wire asked for, what we resolved and what hangs off the
/// skeleton.
mod dress_census;
pub(crate) use dress_census::DressCensusPlugin;
/// The reveal audit — per-frame, from a snap: every term that decides whether the world about
/// to be shown is actually drawable (decision 1498).
/// The reveal audit: per frame from a snap, every term deciding whether the world is drawable.
mod reveal;
pub(crate) use reveal::RevealAuditPlugin;
/// The two exclusive-`World` reflection dumps: the bevy_ui node inventory and the archetype
/// census — "what is resident right now, and what is it made of".
/// The two exclusive-`World` dumps: the bevy_ui node inventory and the archetype census.
mod world_census;
pub(crate) use world_census::{EntityCensusPlugin, NodeProbePlugin};
/// The schedule census — per schedule, every system with its executor-relevant flags, both
/// worlds: the structural inventory under the 1435 orchestration rows (decision 1437).
/// The schedule census: per schedule in both worlds, every system with its executor flags.
mod sched_census;
pub(crate) use sched_census::SchedCensusPlugin;
/// The frame-stall injector — blocks the main loop on a schedule, reproducing on demand the one
/// thing a probe window can never do to itself: go to the background and come back with one
/// enormous frame delta. Doubles as the plain frame-delta/occlusion monitor at `WOW_STALL=0`.
/// The frame-stall injector: blocks the main loop on a schedule to produce one huge frame delta;
/// at `WOW_STALL=0` a plain frame-delta and occlusion monitor.
mod stall;
pub(crate) use stall::StallPlugin;
/// The clock **every probe schedule reads** — real time, never the virtual clock (decision 0789).
///
/// A probe knob is a wall-clock instruction: "send this at 20 s", "sample 300 frames from 25 s",
/// "resize at 12 s", "exit at 480 s". `Time<Virtual>` cannot honour one — it clamps every frame delta
/// to `max_delta` (250 ms, `bevy_time`'s default), so on any leg that hitches (a streaming burst, an
/// occluded window, a loaded machine) it falls behind real time and drags the whole schedule with it.
/// The knob then means something the operator never asked for, *silently*.
///
/// **Third time this clock has cost us a measurement.** 0615 moved the relayed-move replay off it
/// (and cites the UI script clock as the same lesson before that); then B131's first causal leg was
/// destroyed by it here — the probe-chat hops drifted 40 s → 75 s apart, so windows labelled
/// "parked, ticks off" in fact contained a teleport and live ticks, and an eight-minute leg had to be
/// thrown away (decision 0785's discarded run). A named alias is what makes the next probe get it
/// right without knowing the story: type this, and the mistake is unavailable.
///
/// The one deliberate virtual clock in the harness is the fixture **age** in
/// [`super::drive_capture`] — an age on the clock the effect animates on (and which the capture
/// freezes at save time), which is not a schedule at all.
/// The clock every probe schedule reads: real time. `Time<Virtual>` clamps each frame delta to
/// `max_delta` (250 ms), so on a hitching leg it falls behind and drifts every `<secs>` knob.
pub(crate) type ProbeClock<'w> = Res<'w, Time<bevy::time::Real>>;
#[cfg(test)]
mod tests {
/// **The invariant, checked instead of remembered** (decision 0789).
///
/// Naming the right clock [`ProbeClock`] makes it easy to reach for; it does not make the wrong
/// one unavailable, and `Res<Time>` is the shorter, prelude-blessed, obvious spelling. That
/// asymmetry is precisely why this same clock has now cost three lanes a correctness bug
/// (0615's replay clock, the UI script clock, and B131's discarded probe leg) — each time it was
/// fixed where it was found and left available everywhere else. So the fix is not another
/// convention: it is this test, in the suite the gates already run.
///
/// Adding a virtual clock to the probe harness now means adding yourself to [`ALLOWED`] with a
/// reason, which is a much better conversation than discovering the drift in a thrown-away leg.
/// No probe system under `capture/` reads the virtual clock, except the [`ALLOWED`] entries.
#[test]
fn probe_schedules_read_the_wall_clock() {
/// `(file, system, why it is genuinely an age or a delta on the animating clock)`.
@@ -153,9 +109,7 @@ mod tests {
),
];
// The needles are assembled at runtime so the checker does not flag **its own source** —
// which is exactly what it did on its first run, and is the cheapest possible proof that it
// has teeth.
// Needles are assembled at runtime so the checker does not flag its own source.
let bare = format!(": Res<{}>,", "Time");
let bare_last = format!(": Res<{}>", "Time");
let explicit = format!("Res<{}<{}>>", "Time", "Virtual");
@@ -163,11 +117,7 @@ mod tests {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let mut allowed_seen = 0usize;
// The probe harness: everything under `capture/`. It used to need a hand-added exception
// for the one probe that lived beside the player controller; decision 1174 moved every
// instrument in here, so the directory walk IS the scope again — and the move immediately
// earned its keep by catching `probe_cam`, which had been scheduling on the virtual clock
// outside this checker's reach ever since 0653.
// The probe harness is everything under `capture/`.
let mut stack = vec![src.join("capture")];
let mut files: Vec<std::path::PathBuf> = Vec::new();
while let Some(dir) = stack.pop() {
@@ -189,8 +139,7 @@ mod tests {
let text = std::fs::read_to_string(&path).expect("source is readable");
for (n, line) in text.lines().enumerate() {
let t = line.trim();
// A system param binding the virtual clock. `Time<Real>` is the whole point, and
// `ResMut<Time<Virtual>>` is the capture's own clock *control*, not a read of it.
// A param binding the virtual clock; `ResMut<Time<Virtual>>` is clock control.
let virtual_clock =
(t.ends_with(&bare) || t.ends_with(&bare_last)) || t.contains(&explicit);
if !virtual_clock {
@@ -206,7 +155,7 @@ mod tests {
offenders.is_empty(),
"the probe harness must schedule on the wall clock (`ProbeClock`), not the virtual \
clock — it is clamped to max_delta (250 ms), so any hitching leg silently drifts every \
`<secs>` knob out from under the operator (decision 0789). Offenders:\n {}",
`<secs>` knob out from under the operator. Offenders:\n {}",
offenders.join("\n "),
);
assert!(
@@ -1,30 +1,14 @@
//! The particle census ([`ParticleCensusPlugin`]) — one `PARTICLE_CENSUS_EMITTER` line per live
//! emitter plus the `PARTICLE_CENSUS` summary, including the draw-distance accounting that is
//! B39's numeric form.
//! The particle census ([`ParticleCensusPlugin`]): a `PARTICLE_CENSUS_EMITTER` line per live
//! emitter and a `PARTICLE_CENSUS` summary with draw-distance accounting.
use bevy::prelude::*;
use super::ProbeClock;
/// The particle census (`WOW_PARTICLE_CENSUS=<secs>`): once, `t` seconds in, print one line per
/// live emitter (blend, file flags, sampled rate keys, texture, live count) plus a machine-
/// readable total — the like-for-like number to put beside a reference-trace quad count (the
/// login whirlpool investigation: the real client draws 793 particle quads across 23 draws in
/// one `UI_MainMenu` frame). Works at any state — the glue screens included, unlike the
/// in-world-gated FPS probe.
///
/// It also measures **draw distance** (decision 0678): each emitter's planar depth along
/// camera-forward — the coordinate the far-clip wall uses — and the draw-set gate's verdict, with
/// `drawn_beyond_wall` on the summary line. That is the numeric form of "effects render at
/// unlimited distance" (bug B39): emitters still ticking and drawing past the wall that has already
/// discarded the terrain beneath them. **It must read 0**; a non-zero value is the bug, live.
///
/// **`WOW_PARTICLE_CENSUS=+<secs>` fires that long after the world is first SHOWN** (the loading
/// screen dropping) instead of after app start. Everything that rides the appear ramp — decision
/// 0827/0833's `alpha` column above all — lives in a 2-second window whose start moves by *seconds*
/// between a warm and a cold load, and a wall-clock timer either lands in it or does not: three
/// runs in a row missed it while the question was "does a weapon glow ramp with its wearer?". A
/// probe should not be a dice roll, and the ramp's own trigger is the thing to time from.
/// `WOW_PARTICLE_CENSUS=<secs>`: once, prints one line per live emitter and a total, at any state
/// including glue screens (the reference draws 793 quads in 23 draws on one `UI_MainMenu` frame).
/// `drawn_beyond_wall` counts emitters drawn past the far-clip wall and must read 0.
/// `+<secs>` times from the loading screen dropping, so the appear ramp is caught on any load.
pub(crate) struct ParticleCensusPlugin;
impl Plugin for ParticleCensusPlugin {
@@ -44,12 +28,11 @@ impl Plugin for ParticleCensusPlugin {
}
}
/// [`ParticleCensusPlugin`] state: the fire time and the once-latch.
/// [`ParticleCensusPlugin`] state.
#[derive(Resource)]
struct ParticleCensus {
at: f32,
/// `at` is measured from the world being shown, not from app start — and is rewritten into an
/// absolute time the first frame the loading screen stops covering.
/// `at` counts from the world being shown; made absolute when the loading screen drops.
after_shown: bool,
fired: bool,
}
@@ -66,8 +49,7 @@ fn fire_particle_census(
Option<&bevy::camera::visibility::RenderLayers>,
)>,
) {
// Latch the shown-relative deadline the first frame the screen drops (and only then: while it
// still covers there is no ramp to be relative to).
// Latch the shown-relative deadline the first frame the screen drops.
if probe.after_shown {
if screen.covering() {
return;
@@ -81,26 +63,10 @@ fn fire_particle_census(
probe.fired = true;
let mut total = 0usize;
let mut n = 0usize;
// The B39 columns (decision 0678). Per emitter: its planar depth along camera-forward (the
// coordinate the far-clip wall is measured in) and the draw-set gate's live verdict.
//
// **`drawn_beyond_wall` is the number that names the bug.** It counts emitters the gate is
// still ticking and drawing at a depth where the detailed world — the terrain under them
// 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", 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
// should, they are simply frozen. A fix that despawned them would be the wrong fix.
//
// **Booth-layered emitters are excluded from the distance accounting** — the same layer filter
// `simulate_particles` uses to pick a booth's camera. The portrait/glue scenes are parked
// thousands of yards from the world and drawn by their OWN camera, so the world camera's wall
// says nothing about them; counting them read as 28 phantom "effects past the wall" (all of
// them Karazahn braziers and night-elf glows at ~7080 yd) on a build where the world was
// already clean. Measuring the right subject is the instrument's job, not the reader's.
// Per emitter: planar depth along camera-forward (the far-clip wall's coordinate) and the
// draw-set gate's verdict. Past the wall the gate hides the emitter and freezes its pool, so
// `beyond_wall` counts frozen emitters and `drawn_beyond_wall` must be 0. Booth-layered
// emitters are drawn by their own camera, far from the world, and are left out.
let cam_tf = cam.iter().next();
let mut beyond_wall = 0usize;
let mut drawn_beyond_wall = 0usize;
@@ -110,8 +76,7 @@ fn fire_particle_census(
for (e, fade, layers) in &emitters {
let world_layer =
layers.is_none_or(|l| l.intersects(&bevy::camera::visibility::RenderLayers::default()));
// Depth to the OWNER's fade sphere where there is one (the gate's own subject), else the
// emitter's live anchor — so the number always names what the gate actually tests.
// Depth to the owner's fade sphere if any, else the anchor: what the gate tests.
let depth = cam_tf.map(|t| {
let center = fade.map_or_else(|| e.anchor_world(), |f| f.center);
let radius = fade.map_or(0.0, |f| f.radius);
@@ -138,10 +103,7 @@ fn fire_particle_census(
let lane = if world_layer { "world" } else { "booth" };
let c = fade.map_or_else(|| e.anchor_world(), |f| f.center);
format!(
// `has_fade`, not `gated`: this column prints whether the emitter carries an
// `EmitterFade`, and calling it `gated` made it read as the draw-set verdict —
// which is `drawn` (its inverse), right beside it. A tile-emitter reading was
// taken backwards off the old label before it was renamed (decision 2046).
// `has_fade` is whether it carries an `EmitterFade`; the verdict is `drawn`.
" depth={d:.1} drawn={drawn} lane={lane} has_fade={} at=({:.0},{:.0},{:.0})",
fade.is_some(),
c.x,
@@ -151,8 +113,7 @@ fn fire_particle_census(
})
.unwrap_or_default();
let d = e.def();
// The rate summary: the constant (the common shape), else each slot's key count — the
// full per-sequence choreography lives in `benilla-extract m2anim`, not a census line.
// The constant rate, else each slot's key count (`benilla-extract m2anim` has the rest).
let rate_keys: Vec<String> = match d.timing.constant_rate() {
Some(r) => vec![format!("{r:.1}")],
None => d
@@ -163,9 +124,7 @@ fn fire_particle_census(
.map(|(s, (_, r, _))| format!("s{s}:{}k", r.map_or(0, <[(f32, f32)]>::len)))
.collect(),
};
// The orientation fingerprint (world plane normal + thickness/radius) is the numeric
// "which way does this cloud face" — the flat-vs-standing question a screenshot can
// only suggest (the InstancePortal swirl-plane investigation).
// Which way the cloud faces: world plane normal, thickness and radius.
let plane = e
.cloud_fingerprint()
.map(|(c, nrm, thick, radius)| {
@@ -183,9 +142,7 @@ fn fire_particle_census(
d.params.sample(None, 0.0, 0.0).lifespan,
d.texture.as_deref().unwrap_or("-"),
e.live(),
// The frame's composed MODEL alpha (decision 0827/0833) — the number that answers
// "this cloud is drawing, why can't I see it / why is it full strength?". An effect on
// a unit that has not appeared yet reads ~0; one with no model above it reads 1.
// The composed model alpha: ~0 on a unit not yet appeared, 1 with no model above.
e.render_alpha(),
);
total += e.live();
@@ -196,9 +153,7 @@ fn fire_particle_census(
} else {
0.0
};
// The camera pose goes on the line so a census is self-describing: every distance number here
// is measured from it, and a probe whose `.go` silently failed otherwise reports crisp numbers
// about the wrong place.
// Every distance is from this camera; it also shows a failed `.go`.
let where_ = cam_tf
.map(|t| {
let p = t.translation();
+10 -41
View File
@@ -1,38 +1,11 @@
//! The **reveal audit** (`WOW_REVEAL=<frames>`) — one line per frame from a snap, naming every
//! term that decides whether the world the player is about to be shown is actually there.
//! `WOW_REVEAL=<frames>`: one `REVEAL` line per frame after a snap (a same-map teleport or a
//! worldport), naming every term that decides whether the destination is drawn. Residency columns
//! (`res`, `focus`, `scene`, `place`, `coll`, `merge`, `gx`) are the terms behind
//! [`WorldLoadProgress::presentable`], which the cover waits on; draw columns (`drawn`, `hid`,
//! `sel`, `room`, `win`, `pvslag`) are what the frame put on screen.
//!
//! "Teleported and the city wasn't drawn for a frame or more" is a *temporal* claim about the
//! frames either side of a reveal, and neither a screenshot nor the loading screen's own 3-second
//! wait line can hold one: the screenshot is one frame with no state attached, and the wait line
//! only speaks for loads slow enough to be stuck. This prints the whole window — the cover, the
//! residency terms behind [`WorldLoadProgress::presentable`], the settle hold, and the retained
//! pass's collected-vs-published region counts — so "which term let the reveal through" is read
//! off a column instead of guessed.
//!
//! ```text
//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
//! WOW_PROBE_CHAT=".go xyz -9250.45 160.86 67.90 0;.tele stormwind" \
//! WOW_PROBE_CHAT_AT=20 WOW_PROBE_CHAT_EVERY=25 WOW_REVEAL=120 \
//! WOW_PROBE_EXIT_AT=70 cargo run -q -p benilla | grep REVEAL
//! ```
//!
//! The arming edge is the snap itself (a same-map teleport or a worldport), so the window covers
//! the frames a cover would have to be up for — and, when none is, the frames the player is
//! looking at the destination through nothing at all.
//!
//! **It runs in `Last`, and that is load-bearing.** Half of what decides whether a frame has a
//! world in it is settled in `PostUpdate` — bevy's visibility check, the exterior-scene gate, the
//! retained pass's own scene walk — and a line printed in `Update` reports those columns from the
//! *previous* frame. Against a one-or-two-frame artefact an off-by-one instrument is worse than
//! none: it attributes the hole to the frame beside it. In `Last` every column below is this
//! frame's, residency and draw alike.
//!
//! The columns split into two halves on purpose. **Residency** (`res`, `focus`, `scene`, `place`,
//! `coll`, `merge`, `gx`) is *what the world has* — the terms behind
//! [`WorldLoadProgress::presentable`], the ones the cover is raised and cleared on. **Draw**
//! (`drawn`, `hid`, `sel`, `room`, `win`, `pvseye`) is *what the frame put on screen*. A reveal
//! defect where every residency term reads ready and the draw half has collapsed is not a
//! streaming bug at all — it is the visibility authority answering about somewhere else.
//! Runs in `Last`: visibility, the exterior-scene gate and the retained pass settle in
//! `PostUpdate`, so an `Update` line would report them a frame late.
use bevy::prelude::*;
@@ -47,9 +20,7 @@ struct RevealAudit {
frames: u32,
/// Frames printed since the current arm; `None` = not armed.
n: Option<u32>,
/// [`ProbeClock`] seconds at the arming snap — the `t=` column. The wall clock, like every
/// other probe schedule (decision 0789): a reveal window is measured in real milliseconds,
/// and the virtual clock clamps exactly the hitching frames this instrument exists to see.
/// [`ProbeClock`] seconds at the arming snap, the base of the `t=` column.
since: f32,
}
@@ -66,8 +37,7 @@ impl Plugin for RevealAuditPlugin {
n: None,
since: 0.0,
})
// In `Last`: after the streamer published residency, after the screen decided what to do
// with it, and after `PostUpdate` settled what actually draws (see the module docs).
// `Last`: after residency, the loading screen and `PostUpdate`'s visibility.
.add_systems(Last, drive_reveal_audit);
}
}
@@ -105,8 +75,7 @@ fn drive_reveal_audit(
.as_deref()
.map_or((0, 0, 0), benilla_world::static_gx::StaticGx::draw_census);
let seen = census.take();
// How far the visibility authority's pose is from the eye this frame is actually drawn from
// — ~0 in steady play, the whole jump on a snap frame it has not caught up with.
// Distance from the visibility authority's eye to the drawn camera: ~0 in steady play.
let pvs_lag = match (seen.pvs_eye, cam.iter().next()) {
(Some(eye), Some(now_eye)) => eye.distance(now_eye.translation()),
_ => f32::NAN,
+26 -68
View File
@@ -1,36 +1,15 @@
//! The probe **run's own shell** — everything about the process and its window rather than
//! about what is being measured: the un-occludable, parked window ([`ProbeFocusPlugin`]), the
//! bounded lifetime ([`ProbeExitPlugin`]), and the mid-run resize ([`ProbeResizePlugin`]).
//! Every scripted probe rides these, whatever instrument it is running.
//! The probe run's shell: the un-occludable, parked window ([`ProbeFocusPlugin`]), the bounded
//! lifetime ([`ProbeExitPlugin`]) and the mid-run resize ([`ProbeResizePlugin`]).
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, 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".
/// It is not: **every** scripted probe schedule is wall-clock ([`ProbeClock`]), so a throttled run
/// doesn't just measure slowly, it *executes the wrong script* — one session's mounted-jump run
/// fired `W@16` and `Space@19` in the SAME frame at ~1 fps, i.e. it jumped from a standstill
/// instead of mid-run, and the leg had to be re-read to notice (decision 0906). Any probe env arms
/// it, so a key/chat/Lua probe defends itself exactly like the FPS one.
///
/// **The parking half is the other side of that bill** (decision 1148). Asserting `AlwaysOnTop`
/// every frame silently defeats [`benilla_world::bgwin`]'s whole design — it overrides the
/// `AlwaysOnBottom` birth cage *and* the `Normal` handed back at release — so an instrumented run
/// sits on top for its entire life however politely it was launched. At the full-size default that
/// is a screen-filling window over whatever the director is doing, and a session that fires six
/// probes fires six of them (which is how it got reported). The answer is not to drop the
/// assertion — the throttle is real — but to shrink what is being asserted:
/// [`benilla_world::bgwin::no_pixel_run`] sizes such a run at 640×360 and this parks it in the top-right
/// corner. A run that photographs pixels is excluded from both and keeps the full window.
///
/// Write-gated on both counts: re-marking `Window` every frame would re-apply its whole state
/// through winit, and the park is one-shot, latched once a monitor is actually readable (the
/// monitor entities do not exist on frame 1).
/// Keeps a probe window un-occludable: macOS throttles a fully covered window to ~1 fps, which
/// on the wall clock ([`ProbeClock`]) collapses scheduled steps into one frame. A no-pixel run
/// ([`benilla_world::bgwin::no_pixel_run`], 640×360) is also parked once, per [`Park`]; a run that
/// photographs pixels keeps the full window. Pinning overrides [`benilla_world::bgwin`]'s levels.
/// Writes only on change: marking `Window` changed re-applies its whole state through winit.
pub(crate) struct ProbeFocusPlugin;
impl Plugin for ProbeFocusPlugin {
@@ -39,34 +18,25 @@ impl Plugin for ProbeFocusPlugin {
}
}
/// Where a no-pixel probe window goes, and whether it is pinned on top — `WOW_PROBE_PARK`.
///
/// This is a **dial, not a preference**, and it exists because the thing it trades against is
/// recorded as unproven. `AlwaysOnTop` costs the director screen for every probe a session fires;
/// what it buys is immunity from the macOS occlusion throttle, whose evidence is decision 0713's
/// *"one suspect survives, unproven"* (stall magnitudes of 1026–1053 ms sitting on
/// `CAMetalLayer.nextDrawable`'s 1 s timeout) promoted to fact by 0777 and built on by 0906. The
/// probe line already stamps `occluded_frames=`, so each setting is measurable against the others
/// on the same pin — which is the only way to retire the on-top assertion honestly.
/// `WOW_PROBE_PARK`: where a no-pixel probe window goes and whether it is pinned on top; compare
/// settings by the probe line's `occluded_frames=`.
#[derive(Clone, Copy, PartialEq)]
enum Park {
/// Top-right corner, `AlwaysOnTop`. Today's behaviour.
/// Top-right corner, `AlwaysOnTop`; the default.
Corner,
/// Mostly past the right edge — a [`PARK_EDGE_SLIVER`]-wide strip left on screen, at the
/// NORMAL level. macOS marks a window `NSWindowOcclusionStateVisible` if *any* part of it is
/// visible, so a sliver should be enough to dodge the throttle at ~1% of the screen cost.
/// A [`PARK_EDGE_SLIVER`]-wide strip on screen at the normal level: macOS counts a window
/// visible if any part of it is.
Edge,
/// Wholly past the right edge, NORMAL level. The best case if AppKit allows it — AppKit
/// constrains ordinary titled windows back onto a screen (`constrainFrameRect:toScreen:`), so
/// this may simply not stick; that is the assumption the dial exists to test.
/// Wholly past the right edge, normal level; AppKit may constrain it back
/// (`constrainFrameRect:toScreen:`).
Off,
}
/// How much of an [`Park::Edge`] window stays on screen (logical px).
const PARK_EDGE_SLIVER: f32 = 32.0;
/// Gap (logical px) a [`Park::Corner`] window keeps from the screen's top and right edges — clear
/// of the menu bar, which a window at `y = 0` would sit under.
/// Gap (logical px) a [`Park::Corner`] window keeps from the top and right edges, clear of the
/// menu bar.
const PROBE_WINDOW_MARGIN: f32 = 36.0;
fn park_mode() -> Park {
@@ -87,9 +57,7 @@ fn keep_probe_window_on_top(
};
let no_pixel = benilla_world::bgwin::no_pixel_run();
let mode = park_mode();
// Only `Corner` needs the pin: the other two get out of the way by *position*, which is the
// point of measuring them — an edge/offscreen window that never reports `occluded_frames` has
// retired the assertion on evidence instead of preference.
// A pixel run is always pinned; a no-pixel run only in `Corner`, the others dodge by position.
let want_top = !no_pixel || mode == Park::Corner;
let level = if want_top {
bevy::window::WindowLevel::AlwaysOnTop
@@ -102,10 +70,9 @@ fn keep_probe_window_on_top(
if *parked || !no_pixel {
return;
}
// `WindowPosition::At` is in PHYSICAL pixels while the window's own resolution is logical, so
// the width has to be scaled before it is subtracted.
// `WindowPosition::At` is physical pixels, the resolution logical: scale the width.
let Some(m) = monitors.iter().next() else {
return; // no monitor entity yet — try again next frame
return; // no monitor entity yet (frame 1)
};
let scale = m.scale_factor as f32;
let width = (w.resolution.width() * scale) as i32;
@@ -130,9 +97,7 @@ fn keep_probe_window_on_top(
);
}
/// The probe self-termination as its own plugin, registered whenever `WOW_PROBE_EXIT_AT` is set
/// — it used to ride inside [`super::ProbeLuaPlugin`], so a chat/key-only probe's exit knob silently
/// did nothing (the 0441 flourish probe hung past its window on exactly that).
/// The probe self-termination, registered whenever `WOW_PROBE_EXIT_AT` is set.
pub(crate) struct ProbeExitPlugin;
impl Plugin for ProbeExitPlugin {
@@ -147,10 +112,7 @@ impl Plugin for ProbeExitPlugin {
}
}
/// The probe run's clean self-termination (`WOW_PROBE_EXIT_AT=<secs>`, off when unset): exit the
/// app after N wall seconds, so a scripted live probe (`WOW_PROBE_LUA`/`WOW_PROBE_CHAT`) is one
/// foreground command with a bounded lifetime — no external kill, no orphaned window (0437's
/// probe rounds prompted it; generic to every future live probe).
/// `WOW_PROBE_EXIT_AT=<secs>`: exits the app after that many wall seconds; off when unset.
#[derive(Resource)]
struct ProbeExit {
at: Option<f32>,
@@ -167,10 +129,8 @@ fn fire_probe_exit(
info!("probe-exit: {at}s elapsed — exiting");
probe.fired = true;
exit.write(AppExit::Success);
// The hard backstop rides its own OS thread: the polite AppExit stops the Update
// schedule, so an in-schedule backstop can never fire — exactly the hang it existed
// for (a winit/net-thread teardown hang leaves a zombie client holding the account;
// the 0451 probe reproduced it). A probe run has nothing to lose.
// The hard backstop runs on its own thread, since `AppExit` stops `Update`: a teardown
// hang would otherwise leave a client holding the account.
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("probe-exit: still alive 5s after AppExit — hard exit");
@@ -179,10 +139,8 @@ fn fire_probe_exit(
}
}
/// The window-resize probe (`WOW_PROBE_RESIZE="<secs>:<W>x<H>"`, logical units): resize the
/// primary window mid-run — the headless stand-in for a mac fullscreen toggle or a window drag,
/// so resize-reactive layout (the glue screens' rescale rebuild) is verifiable in one scripted
/// run: open, resize at `t`, shoot after (`WOW_LOGIN_SHOT_OUT` fires at 8 s).
/// `WOW_PROBE_RESIZE="<secs>:<W>x<H>"`: resizes the primary window to that logical size mid-run,
/// standing in for a fullscreen toggle or a window drag.
pub(crate) struct ProbeResizePlugin;
impl Plugin for ProbeResizePlugin {
@@ -206,7 +164,7 @@ impl Plugin for ProbeResizePlugin {
}
}
/// [`ProbeResizePlugin`] state: the fire time, the target logical size, and the once-latch.
/// [`ProbeResizePlugin`] state; `size` is logical.
#[derive(Resource)]
struct ProbeResize {
at: f32,
@@ -1,33 +1,19 @@
//! The schedule census (`WOW_SCHED_CENSUS=1`): the structural inventory behind the 1435 band
//! map's orchestration rows — per schedule, every system with its executor-relevant flags
//! (non-`Send`, exclusive, has-deferred), for BOTH worlds. Born with decision 1437: the parked
//! frame pays ~5.4 ms/f of pure scheduling (schedule selves + executor + empty command applies
//! across ~600 executed systems), and neither consolidation nor executor choices can be argued
//! about until the population has names and counts.
//!
//! Why runtime one-shots instead of a pre-run `Schedule::initialize` walk: bevy removes a
//! schedule from the `Schedules` resource WHILE it runs (`World::schedule_scope`), so no single
//! vantage sees everything — and pre-run initialization is the wrong tool twice over
//! (`Extract` params panic without the runtime-only `MainWorld`, and `Local<impl FromWorld>`
//! state may read resources Startup hasn't inserted yet). Two vantages per world cover each
//! other: `Update` and `PostUpdate` in the main world, `ExtractSchedule` and `Render` in the
//! render app. Only the `Main`/`RenderStartup` runners stay invisible (each is the fixed
//! one-system runner bevy ships).
//! `WOW_SCHED_CENSUS=1`: per schedule in both worlds, every system with its executor flags
//! (non-`Send`, exclusive, has-deferred). Bevy removes a running schedule from `Schedules`
//! (`World::schedule_scope`), so two vantages per world cover each other: `Update`/`PostUpdate`
//! in main, `ExtractSchedule`/`Render` in the render app. Only the `Main`/`RenderStartup` runners
//! stay unseen.
use bevy::prelude::*;
use bevy::render::{ExtractSchedule, Render, RenderApp};
/// Frames to wait before dumping — deep enough that every schedule has run at least once (the
/// census reads the live graph, not what a first frame happens to have reached).
/// The frame to dump at: late enough that every schedule has run once.
const CENSUS_FRAME: u32 = 10;
/// Frames to wait before the census run exits — the render app runs a frame behind the main
/// world (pipelined), so give its vantages room past [`CENSUS_FRAME`] before the app closes.
/// The frame the run exits at; the pipelined render app runs a frame behind [`CENSUS_FRAME`].
const EXIT_FRAME: u32 = 40;
/// The census instrument. Purely structural — it does not need the world, a server, or a
/// character: the schedule graph is fixed once the plugins have built, so a login-screen run
/// answers for every state.
/// The census; the graph is fixed once plugins build, so a login-screen run answers for all states.
pub(crate) struct SchedCensusPlugin;
impl Plugin for SchedCensusPlugin {
@@ -36,8 +22,7 @@ impl Plugin for SchedCensusPlugin {
.add_systems(Update, census_vantage("main", "Update"))
.add_systems(PostUpdate, census_vantage("main", "PostUpdate"))
.add_systems(Last, census_exit);
// The render app still lives in the main `App` here (pipelining detaches it at cleanup,
// after every plugin has built). Its two vantages mirror the main world's pair.
// The render app is still a sub-app here; pipelining detaches it at cleanup.
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app
.init_resource::<DumpedSchedules>()
@@ -47,13 +32,11 @@ impl Plugin for SchedCensusPlugin {
}
}
/// Which schedules this world's earlier vantage already printed — the later vantage adds only
/// what the earlier one could not see (itself, chiefly).
/// Schedules this world has printed; the later vantage adds only what the earlier could not see.
#[derive(Resource, Default)]
struct DumpedSchedules(std::collections::HashSet<String>);
/// One vantage: an exclusive one-shot that, at [`CENSUS_FRAME`], prints every schedule visible
/// in `Schedules` that no earlier vantage in this world has printed.
/// One vantage: at [`CENSUS_FRAME`], prints every visible schedule this world has not printed.
fn census_vantage(
world_tag: &'static str,
vantage: &'static str,
@@ -80,9 +63,8 @@ fn census_vantage(
}
}
/// Print one schedule: the tally line, then one line per system. `systems()` errors only on an
/// uninitialized graph — impossible by [`CENSUS_FRAME`] for a schedule that runs, and a
/// registered-but-never-run schedule is exactly worth flagging as such.
/// Prints one schedule's tally, then one line per system. By [`CENSUS_FRAME`] only a schedule that
/// never ran is uninitialized, and it is flagged.
fn dump_schedule(
world_tag: &str,
vantage: &str,
@@ -129,7 +111,7 @@ fn dump_schedule(
}
}
/// Close the run once both worlds have had time to print — the census is its own whole run.
/// Exits once both worlds have printed.
fn census_exit(mut frame: Local<u32>, mut exit: MessageWriter<AppExit>) {
*frame += 1;
if *frame == EXIT_FRAME {
+22 -75
View File
@@ -1,63 +1,16 @@
//! The **frame-stall injector** (`WOW_STALL="<ms>[,<every_s>[,<after_s>[,<frames>]]]"`) — the
//! instrument that makes "I tabbed away and came back to X" reproducible.
//! `WOW_STALL="<ms>[,<every_s>[,<after_s>[,<frames>]]]"`: blocks the main loop for `<ms>` on each
//! of `<frames>` consecutive frames, every `<every_s>` seconds from `<after_s>`, reproducing a
//! backgrounded window, which a kept-visible probe window ([`super::ProbeFocusPlugin`]) never is.
//!
//! A probe window cannot be tabbed away from: every scripted live probe asserts `AlwaysOnTop`
//! ([`super::ProbeFocusPlugin`], decision 0906) precisely so the OS cannot throttle it. So the one
//! class of bug that only appears *because* the window went to the background — the client running
//! for seconds at ~1 fps and then resuming — had no reproduction at all. This is that reproduction:
//! it blocks the main loop for `<ms>` on each of `<frames>` consecutive frames, every `<every_s>`
//! seconds, starting at `<after_s>`. Nothing else.
//! Losing focus alone costs nothing (bevy's default `WinitSettings::game()` keeps an unfocused
//! window at 60 Hz); a fully covered macOS window blocks ~1 s in `CAMetalLayer.nextDrawable` every
//! frame. A ten-second tab-away is therefore `WOW_STALL="1000,20,60,10"`, and `WOW_STALL="0"` is
//! the monitor alone. Distinct from `WOW_STALL_INJECT` (`crate::perf::stall`), one watchdog sleep.
//!
//! **What backgrounding actually does, and why this shape is the faithful one.** Two independent
//! mechanisms, and only the second one bites:
//!
//! - *winit* — we never set [`bevy::winit::WinitSettings`], so we get its `Default`, which is
//! `WinitSettings::game()`: `focused_mode: Continuous`, `unfocused_mode:
//! reactive_low_power(1/60 s)` (`bevy_winit-0.18.1/src/winit_config.rs:19-22`). An unfocused
//! window therefore still targets **60 Hz**; losing focus on its own costs nothing.
//! - *macOS* — a window that is fully **covered** is throttled at the drawable: every
//! `CAMetalLayer.nextDrawable` blocks about a second, so the run continues at ~1 fps for as long
//! as it is covered (decisions 0713/0777; the director's correction in 0906 — "any covering
//! window, not just the lock screen"; 1355 measured the signature as a `p99 ≈ 1020 ms`
//! metronome). A ten-second glance at a terminal is therefore ~10 consecutive one-second frames,
//! which is why `<frames>` exists: a *single* long frame is a hitch, and a hitch is not what
//! backgrounding does.
//!
//! So the knob that reproduces a ten-second tab-away is `WOW_STALL="1000,20,60,10"`.
//!
//! **Not to be confused with `WOW_STALL_INJECT`** (`crate::perf::stall`, decisions 0713/1637),
//! whose name is one suffix away. That one fires **a single** sleep at a single instant, and it
//! exists to prove the stuck-main-thread *watchdog* fires — it is a test affordance for an
//! instrument, not a reproduction of a gameplay state. This one repeats, blocks `<frames>` in a
//! row, and carries the frame-delta/occlusion monitor, which is the whole difference: 1663 was
//! reproducible only with **consecutive** long frames (one alone loses the deck for a frame and
//! recovers; two drop the rider through it). The two should probably become one knob — flagged,
//! not done here.
//!
//! **`WOW_STALL="0"` injects nothing** and leaves only the monitor running — the "what is this
//! run's frame pacing actually doing" reader, which is how the premise above gets checked on a real
//! run instead of assumed. Every run prints, per window:
//!
//! ```text
//! STALL_WATCH t=35.0 frames=299 p50=16.7 p99=17.9 max=33.4 focused=1 occluded=0
//! STALL t=35.0 sleeping 1000 ms (1/10)
//! STALL_HIT t=36.0 injected=1000 wall_dt=1016.4 real_dt=16.7 virt_dt=16.7 clamped=0
//! ```
//!
//! **Three clocks on that line, and they are three different clocks — that is the point.**
//!
//! - `wall_dt` is [`Instant`] to [`Instant`] across the main-schedule pass: the true frame time,
//! and the clock `transport::Transport::cycle_ms` runs on (`anchor.at.elapsed()`).
//! - `real_dt` is `Time<Real>`'s delta, which under pipelined rendering is **not** the wall clock:
//! `time_system` stamps it from an `Instant` the *render world* sends over a `bounded(2)` channel
//! (`bevy_time-0.18.1/src/lib.rs:152-171`, `bevy_render-0.18.1/src/renderer/mod.rs:113`), so it
//! arrives a frame or two late — bevy's own source carries a `TODO: Figure out how to handle
//! this when using pipelined rendering` right above it.
//! - `virt_dt` is what every consumer of `Res<Time>` integrates: `real_dt` after the frame pacer,
//! clamped to `Time<Virtual>`'s stock 250 ms `max_delta`. `clamped=1` marks the frames where the
//! clamp bit.
//!
//! A `focused=`/`occluded=` transition prints its own line the moment it lands.
//! `STALL_HIT` prints three clocks: `wall_dt`, [`Instant`] across the main-schedule pass (the
//! clock `transport::Transport::cycle_ms` runs on); `real_dt`, `Time<Real>`, which under pipelined
//! rendering is stamped from an `Instant` the render world sends and lags a frame or two;
//! `virt_dt`, after the frame pacer and the 250 ms `max_delta` clamp (`clamped=1`).
use core::time::Duration;
use std::time::Instant;
@@ -68,8 +21,7 @@ use bevy::window::WindowOccluded;
use super::ProbeClock;
/// Default seconds between stall bursts, and default seconds of run before the first one — long
/// enough that login, world entry and the first streaming burst are never what is being measured.
/// Default seconds between bursts and before the first, clear of login and world entry.
const DEFAULT_EVERY_SECS: f32 = 20.0;
const DEFAULT_AFTER_SECS: f32 = 30.0;
/// How often the monitor prints its `STALL_WATCH` summary, seconds.
@@ -115,22 +67,20 @@ impl Plugin for StallPlugin {
focused: None,
occluded: false,
})
// `Last`, so the block lands at the frame boundary: the sleep is over by the time the
// next frame's `First` stamps the clocks, and it therefore shows up as that frame's
// delta — which is the shape an unscheduled/throttled frame has.
// `Last`: the sleep ends before the next `First` stamps the clocks, so it is that
// frame's delta, as a throttled frame's is.
.add_systems(Last, drive_stall);
}
}
/// [`StallPlugin`] state: the knob, the schedule, and the current window's samples.
/// [`StallPlugin`] state.
#[derive(Resource)]
struct Stall {
/// Milliseconds to block for, `0` = monitor only.
/// Milliseconds to block for; `0` is monitor only.
ms: u64,
/// Seconds between bursts.
every: f32,
/// Consecutive frames blocked per burst — a backgrounded window is ~1 fps for as long as it
/// is covered, so one long frame is a hitch and `burst` long frames are a tab-away.
/// Consecutive frames blocked per burst: one long frame is a hitch, several a tab-away.
burst: u32,
/// Wall-clock second of the next burst.
next_stall: f32,
@@ -138,15 +88,13 @@ struct Stall {
left: u32,
/// Wall-clock second of the next `STALL_WATCH` summary.
next_watch: f32,
/// Set on the frame that slept; read (and reported) on the frame that paid for it.
/// Set on the frame that slept; reported on the frame that paid for it.
pending: Option<u64>,
/// End of the previous main-schedule pass — the true wall-clock frame delta, which under
/// pipelined rendering `Time<Real>` is not (see the module header).
/// End of the previous main-schedule pass, for the wall-clock frame delta.
last: Option<Instant>,
/// This window's true frame deltas, milliseconds.
samples: Vec<f64>,
/// Last seen window focus, `None` until the first read — so the first frame prints the state
/// rather than a transition into it.
/// Last seen window focus; `None` until the first read, which prints the state.
focused: Option<bool>,
/// Last seen occlusion, from `WindowOccluded`.
occluded: bool,
@@ -159,7 +107,7 @@ impl Stall {
}
}
/// Report the frame that just ran, then decide whether to block the next one.
/// Reports the frame that just ran, then decides whether to block the next one.
fn drive_stall(
real: ProbeClock,
virt: Res<Time<Virtual>>,
@@ -175,8 +123,7 @@ fn drive_stall(
stall.last = Some(at);
stall.samples.push(wall_ms);
// Window state, printed on transition — the premise check: a run that was occluded (or never
// focused) was measuring the OS, not us.
// Window state, printed on transition: an occluded run measures the OS throttle.
for o in occlusions.read() {
if o.occluded != stall.occluded {
stall.occluded = o.occluded;
@@ -1,47 +1,16 @@
//! The **ribbon-trail census** (`WOW_TRAIL_CENSUS=<secs>[,<every>]`) — the instrument that turns
//! *"the weapon particles trail behind while the lift goes up"* into a line of numbers.
//! `WOW_TRAIL_CENSUS=<secs>[,<every>]`: per ribbon trail, its extent in the world and in its
//! transport's frame. A trail's length is `host speed × edgeLifetime`; on a moving deck the edges
//! ride the deck's pose, so a still rider on a vertical lift reads `world_dy ≈ 0`, where edges
//! frozen in the world would read `deck speed × edgeLifetime`.
//!
//! A streak in a screenshot cannot say whether it is **right**. A weapon trail is *supposed* to
//! smear — that is the whole effect, and its length is `host speed × edgeLifetime` by design. The
//! bug the director reported is not "there is a streak", it is that the streak is drawn against
//! the **world** while the host is standing still on a moving **deck**. Those two look identical
//! in a still frame and are one subtraction apart in numbers:
//! - `world_dy`: the strip's vertical extent in world space.
//! - `deck_ext`: the extent in the transport's frame. [`benilla_world::ride_frame::ride_matrix`]
//! is a translation and a yaw, so vertically it equals the world reading; it differs only on
//! a turning transport.
//!
//! - **`world_dy`** — the strip's vertical extent in world space, and **the observable**. A rider
//! standing still on a moving deck must draw a streak that is *short in world space*: with the
//! ride frame the edges are stored on the deck and re-projected through its live pose, so the
//! whole strip travels rigidly with the car and never stretches. Without it the edges are frozen
//! in the world while the car climbs out from under them, and the strip grows to
//! `deck speed × edgeLifetime`. So on a **vertical** lift the reading is simply: `world_dy ≈ 0`
//! is right, `world_dy ≈ deck speed × edgeLifetime` is the bug (decision 1591's unbuilt gap,
//! 1661's fix).
//! - **`deck_ext`** — the strip's extent measured *inside the transport's frame*. **It cannot
//! differ from the world reading in Y**: `A` is `translate · Rz` (see
//! [`benilla_world::ride_frame::ride_matrix`]), and a yaw plus a translation leaves a vertical
//! extent invariant. It earns its column only on a **turning** transport — a boat swinging
//! through a bend, where the in-plane extent does differ — and stands as a self-check on a lift,
//! where it must equal the world one. (This column was first written claiming `world_dy` large
//! with `deck_dy` small was the finding; that pair is unreachable by construction, and saying so
//! here is cheaper than the next reader re-deriving it.)
//!
//! ```text
//! TRAIL_CENSUS t=85.0 trails=1 riding=1 stamped=1 deck=0xf12000104a00477a deckY=101.96 worst=0.00
//! TRAIL 168v1 bone=24 edges=25 life=0.50s vis=- | world_dy= 0.00 world_ext= 0.25 deck_ext= 0.25
//! ```
//!
//! 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 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 \
//! WOW_PROBE_CHAT=".go xyz -1286.2 189.7 132.0 1" \
//! WOW_TRAIL_CENSUS="35,5" WOW_PROBE_EXIT_AT=75 cargo run -q -p benilla
//! ```
//!
//! The repeat form is the point: one census says what the strip looked like at an instant, a
//! series across the lift's travel says whether the deck-relative spread *grows with the ride*.
//! Off a transport `deck=` reads `-` and the deck columns equal the world ones. A series across
//! the lift's travel shows whether the spread grows. How to run it: `docs/CONTRIBUTING.md`,
//! "Running it unattended".
use bevy::prelude::*;
@@ -64,15 +33,14 @@ impl Plugin for TrailCensusPlugin {
}
}
/// [`TrailCensusPlugin`] state: when the next census fires, and how often after that (`0` = once).
/// [`TrailCensusPlugin`] state; `every` of 0 fires once.
#[derive(Resource)]
struct TrailCensus {
next: f32,
every: f32,
}
/// The axis-aligned extent of a point cloud — `(dx, dy, dz)`. Returns zeros for fewer than two
/// points, which is a trail that has committed nothing yet rather than a trail that is perfect.
/// The axis-aligned extent of a point cloud; zero below two points (nothing committed yet).
fn extent(points: impl Iterator<Item = Vec3>) -> Vec3 {
let (mut lo, mut hi) = (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN));
let mut n = 0u32;
@@ -87,25 +55,17 @@ fn extent(points: impl Iterator<Item = Vec3>) -> Vec3 {
hi - lo
}
/// One line per live ribbon trail, worst deck-relative spread first — the order that puts the
/// finding at the top when there are dozens of trails in a scene.
/// One line per live ribbon trail, worst deck-relative vertical spread first.
fn fire_trail_census(
mut probe: ResMut<TrailCensus>,
time: ProbeClock,
// `ViewVisibility` is OPTIONAL and a trail never has one: a trail entity is spawned with a
// `Transform` and nothing else, deliberately (`ribbons.rs`'s `fade` note — an emitter entity
// carrying a `Visibility` would enlist in a second writer's query, decision 0025). Asking for
// it as a required component matched ZERO trails in every scene, on the ground and on a lift
// alike, and printed `trails=0` as if the client had spawned none.
// Optional: a trail entity carries only a `Transform` (`ribbons.rs`'s `fade` note), so a
// required `ViewVisibility` would match no trail.
trails: Query<(Entity, &RibbonTrail, Option<&ViewVisibility>)>,
// The deck's live pose. Disjoint from the trails above by construction — a transport is never
// a ribbon — and read through the same `ride_matrix` the sim folds with, so the instrument
// cannot drift from the thing it measures.
// The deck's live pose, read through the same `ride_matrix` the sim uses.
decks: Query<&GlobalTransform, Without<RibbonTrail>>,
guids: Query<&crate::net::Guid>,
// The STAMP itself, counted separately from the trails: "nothing is riding" and "something is
// riding but its trail isn't folded" are different bugs with different fixes, and a census
// that cannot tell them apart sends you to the wrong half of the system.
// Ride-frame stamps, counted apart from the trails: a rider whose trail is not folded shows.
stamped: Query<Entity, With<benilla_world::ride_frame::RideFrame>>,
) {
let now = time.elapsed_secs();
@@ -124,8 +84,7 @@ fn fire_trail_census(
for (entity, trail, vis) in &trails {
let world: Vec<Vec3> = trail.strip_world().collect();
let w_ext = extent(world.iter().copied());
// Into the deck's frame. Off a transport this is the identity, so the two columns agree
// and the line reads as its own control.
// Into the deck's frame; off a transport the two columns agree.
let deck = trail
.deck()
.and_then(|d| decks.get(d).ok().map(|gt| (d, gt)));
@@ -1,54 +1,17 @@
//! The **unit-visual census** (`WOW_UNIT_VISUALS=<secs>[,<every>]`) — the instrument that closes
//! B13's symptom: *"the invisible trigger NPC displays as a black block"*.
//! `WOW_UNIT_VISUALS=<secs>[,<every>]`: per streamed entity, which visual its display got. A debug
//! cube means no model we could load; an invisible trigger creature has a model that draws nothing
//! in the reference, and should draw nothing here.
//!
//! Every site of that bug arrived as a screenshot of a black slab, and a screenshot cannot say
//! which of the two things a slab is. Both look identical and they call for opposite fixes:
//! - `cube`: the [`FallbackCube`] marker; `cubes=` on the summary counts them.
//! - `meshes`: direct render children, the body's own batches; `cube=0 meshes=0` is a trigger.
//! - `held`: attach slots on the skeleton ([`HeldAttached::spawned_slots`]), which `meshes`
//! misses since an attached model is a grandchild.
//! - `pick`: parts the mouseover ray-tests across [`crate::target::hover::pick_model_roots`], or
//! fallback-box children; `pick=0` is selectable only by nameplate.
//! - `PENDING`: a visual not built yet, as opposed to one that built nothing.
//!
//! - the entity's display named **no model we could load** — a gap of ours, and the cube is the
//! debug signal that says so (it renders black rather than its authored red only because an
//! unlit `StandardMaterial` catches no light in our scene);
//! - the entity's display named a model which **draws nothing** — how an invisible trigger
//! creature hides in the real client, and nothing should be drawn at all (decision 1403).
//!
//! One line per streamed entity says which, without an eye:
//!
//! ```text
//! UVIS 0xF130003A7200013B Unit display=13069 d=6.4 cube=1 meshes=0 pick=1 held=[] Zandalarian Event Generator
//! ```
//!
//! - **`cube`** is the [`FallbackCube`] marker — literally the arm that spawned it, not a guess
//! from the picture. The headline `cubes=` count is the number that names the bug.
//! - **`meshes`** counts the entity's spawned render children — the BODY's own batches. `cube=0
//! meshes=0` is the *correct* reading for a trigger creature: attached, and drawing nothing.
//! - **`held`** is the attach-slot list actually hanging off its skeleton
//! ([`HeldAttached::spawned_slots`], the dress census's own reader). It is here because
//! `meshes` counts *direct* children and an attached model is a grandchild, so a unit whose
//! whole visible self is a held weapon — the Naxxramas `Unholy Axe`, an `InvisibleStalker` body
//! with an axe in its hand — reads `meshes=0` whether the axe is there or not. That blind spot
//! is what let decision 1656's defect sit under a green census; `held=[main]` is the line that
//! says the weapon arrived.
//! - **`pick`** counts the parts the **mouseover** would actually ray-test — every skinned part
//! across the unit's chained model roots ([`crate::target::hover::pick_model_roots`]: the body,
//! everything it wears, its mount), plus its fallback-box children where it has no skinned parts
//! at all. `pick=0` means the unit cannot be hovered or clicked *at all* and only its name plate
//! can select it — which is the second half of what the Naxxramas weapon mobs reported and what
//! neither `meshes` nor `held` could say: `held=[main]` proves the axe is *attached*, never that
//! it is *clickable* (decision 1658).
//! - **`pending`** marks an entity whose visual has not been built yet (a model still streaming) —
//! never to be confused with one that built nothing, which is the distinction the census exists
//! to keep.
//!
//! 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 \
//! WOW_PROBE_CHAT=".go xyz -11847.0 1280.9 3.2 0" \
//! WOW_UNIT_VISUALS="45,15" WOW_PROBE_EXIT_AT=90 cargo run -q -p benilla
//! ```
//!
//! The repeat form is the load-race half: a census at 45 s and again at 60 s says whether a `cube`
//! is a *verdict* or just an entity whose model had not landed yet — the same transient-vs-ratchet
//! separation `WOW_GROUND_CENSUS` makes for the under-floor report.
//! The repeat form tells a transient cube from a stuck one. How to run it: `docs/CONTRIBUTING.md`,
//! "Running it unattended".
use benilla_assets::coords::bevy_to_wow;
use benilla_protocol::EntityKind;
@@ -60,9 +23,7 @@ use crate::names::NameCache;
use crate::net::{Guid, NetEntity, SelfPlayer};
use crate::target::hover::pick_model_roots;
/// How far from the body the census looks, in yards. Comfortably past the server's own creature
/// visibility radius for a spot the operator has just `.go`ne to, so "nothing found" means the
/// scene is empty rather than the window being tight.
/// Census radius in yards, past the server's visibility radius; `WOW_UNIT_VISUALS_RADIUS` sets it.
const DEFAULT_RADIUS: f32 = 120.0;
pub(crate) struct UnitVisualsPlugin;
@@ -88,8 +49,7 @@ impl Plugin for UnitVisualsPlugin {
}
}
/// [`UnitVisualsPlugin`] state: when the next census fires, how often after that (`0` = once), and
/// how far from the body to look.
/// [`UnitVisualsPlugin`] state; `every` of 0 fires once.
#[derive(Resource)]
struct UnitVisuals {
next: f32,
@@ -97,8 +57,7 @@ struct UnitVisuals {
radius: f32,
}
/// What the census reads per entity: identity, kind + display, pose, and whether its visual has
/// been built yet.
/// What the census reads per entity.
type VisualQuery = (
Entity,
&'static Guid,
@@ -106,20 +65,15 @@ type VisualQuery = (
&'static Transform,
Option<&'static Children>,
Has<VisualAttached>,
// The attach slots standing under this unit — the visual truth for everything that rides a
// BONE rather than the entity, which `Children` above cannot see.
// The attach slots on this unit's bones, which `Children` does not reach.
Option<&'static HeldAttached>,
// …and its mount, the third chained-model root the pick offers (decision 0441).
// Its mount, the third chained-model root the pick offers.
Option<&'static crate::entities::mount::MountChild>,
// The descriptor, read for exactly one thing here: which half of the corpse fork a
// `TYPEID_CORPSE` row is on (decision 1706). A corpse has no name-cache entry, so without this
// every corpse row reads `Corpse … ?` and cannot say whether `meshes=0` means "the bone-pile
// model is missing" or "the dressed body is missing" — opposite findings.
// For a `TYPEID_CORPSE`, which has no name: bone pile or dressed body.
Option<&'static crate::net::ObjectStore>,
);
/// One line per streamed entity within [`UnitVisuals::radius`] of the body — cubes first, then
/// everything else — under a summary line naming the count that matters.
/// One line per streamed entity within [`UnitVisuals::radius`], cubes first, under a summary line.
fn fire_unit_visuals(
mut probe: ResMut<UnitVisuals>,
time: ProbeClock,
@@ -128,8 +82,8 @@ fn fire_unit_visuals(
entities: Query<VisualQuery>,
cubes: Query<(), With<FallbackCube>>,
meshes: Query<(), With<Mesh3d>>,
// The pick's own populations, read exactly as `update_hover` reads them: the skinned parts of
// each chained model root (pass 1/2) and, for a unit with none, its fallback-box children.
// The pick's populations as `update_hover` reads them: skinned parts per chained model root,
// else fallback-box children.
child_sets: Query<&Children>,
rig_parts: Query<(), With<benilla_world::rig_palette::RigPart>>,
box_parts: Query<(), With<benilla_world::interact::CreaturePickPart>>,
@@ -149,8 +103,7 @@ fn fire_unit_visuals(
};
let radius2 = probe.radius * probe.radius;
// `(is_cube, sort key, line)`. Cubes sort to the top: they are the finding, and a busy city
// census is long.
// `(is_cube, sort key, line)`; cubes sort first.
let mut rows: Vec<(bool, i64, String)> = Vec::new();
let (mut cube_n, mut blank_n, mut pending_n) = (0u32, 0u32, 0u32);
let mut cube_displays: Vec<u32> = Vec::new();
@@ -166,7 +119,7 @@ fn fire_unit_visuals(
cube |= cubes.contains(kid);
mesh_n += u32::from(meshes.contains(kid));
}
// A cube IS a mesh child; count it as the cube it is so `meshes` reads as real geometry.
// The cube is a mesh child; exclude it so `meshes` counts real geometry.
mesh_n = mesh_n.saturating_sub(u32::from(cube));
let display = net.display_id.unwrap_or(0);
if !attached {
@@ -179,10 +132,8 @@ fn fire_unit_visuals(
} else if mesh_n == 0 {
blank_n += 1;
}
// What the mouseover would test. The enumeration is the picker's own
// ([`pick_model_roots`]) so the census can never report a set the pick doesn't use; the
// fallback-box leg mirrors `update_hover`'s AABB path, which owns exactly the units the
// skinned path does not.
// What the mouseover tests, via the picker's own [`pick_model_roots`]; the fallback-box
// leg mirrors `update_hover`'s AABB path for units with no skinned parts.
let worn = held.map_or(&[][..], |h| h.spawned_slots().as_slice());
let mut pick_n: u32 = pick_model_roots(entity, worn, mount.map(|m| m.0))
.filter_map(|root| child_sets.get(root).ok())
@@ -218,7 +169,7 @@ fn fire_unit_visuals(
u8::from(cube),
held.join(","),
if attached { "attached" } else { "PENDING" },
// The name, or — for a corpse, which never has one — which fork it took.
// The name, or for a corpse which fork it took.
match (net.kind, store) {
(EntityKind::Corpse, Some(s)) if s.0.corpse_is_bones() => "<bones>",
(EntityKind::Corpse, Some(_)) => "<corpse body>",
@@ -1,16 +1,11 @@
//! The two exclusive-`World` reflection dumps — the pair of one-shots that answer "what is
//! resident right now, and what is it made of" by walking the live `World` itself: the bevy_ui
//! node inventory ([`NodeProbePlugin`]) and the archetype census ([`EntityCensusPlugin`]).
//! They share the shape (fire once at `t`, take `&mut World`, print with the ubiquitous
//! plumbing components filtered out) as well as the question.
//! Two one-shot dumps of the live `World`: the bevy_ui node inventory ([`NodeProbePlugin`]) and
//! the archetype census ([`EntityCensusPlugin`]), each printed without the plumbing components.
use bevy::prelude::*;
/// The bevy_ui node census (`WOW_NODE_PROBE=<secs>`): once, `t` seconds in, print one line per
/// live `ComputedNode` entity — resolved rect (logical px, y-down), visibility, and the entity's
/// full component list — the "who owns this rectangle" instrument for UI drawn OUTSIDE the
/// FrameXML quad pass (the glue widgets, loading screen, overlays), which `WOW_UI_PROBE`'s quad
/// dump can't see. Born hunting a phantom gold-bordered box over the mail window's send tab.
/// `WOW_NODE_PROBE=<secs>`: once, one line per `ComputedNode` entity with its rect (logical px,
/// y-down), visibility and components: the UI outside the FrameXML quad pass, which
/// `WOW_UI_PROBE` cannot see.
pub(crate) struct NodeProbePlugin;
impl Plugin for NodeProbePlugin {
@@ -24,7 +19,7 @@ impl Plugin for NodeProbePlugin {
}
}
/// [`NodeProbePlugin`] state: the fire time and the once-latch.
/// [`NodeProbePlugin`] state.
#[derive(Resource)]
struct NodeProbe {
at: f32,
@@ -45,10 +40,7 @@ fn fire_node_probe(world: &mut World) {
.iter(world)
.next()
.map_or(1.0, bevy::window::Window::scale_factor);
// **`UiGlobalTransform`, not `GlobalTransform`.** Bevy moved UI onto its own 2-D transform;
// a UI entity no longer carries `GlobalTransform` at all, so the old query matched *nothing*
// and this probe answered "0 nodes" — a plausible-looking reading rather than a failure —
// for every run since that upgrade (found 2026-08-29, reaching for it to check a caret).
// `UiGlobalTransform`: a bevy UI entity carries no `GlobalTransform`.
let mut q = world.query::<(
Entity,
&bevy::ui::ComputedNode,
@@ -59,7 +51,7 @@ fn fire_node_probe(world: &mut World) {
.iter(world)
.map(|(e, node, gt, vis)| (e, node.size(), gt.translation, vis.is_none_or(|v| v.get())))
.collect();
// A zero here is now itself the anomaly — the screen has no UI, or the query has rotted again.
// Zero nodes is an anomaly: a bare screen or a query that no longer matches.
if rows.is_empty() {
warn!("node probe: NO ui nodes matched — the screen is bare, or this probe has rotted");
}
@@ -70,7 +62,7 @@ fn fire_node_probe(world: &mut World) {
|it| {
it.map(|c| c.name().shortname().to_string())
.filter(|n| {
// Drop the ubiquitous plumbing components — the signal is the rest.
// Drop the plumbing components.
!matches!(
n.as_str(),
"Transform"
@@ -98,12 +90,8 @@ fn fire_node_probe(world: &mut World) {
}
}
/// The entity census (`WOW_ENTITY_CENSUS=<secs>`, REAL seconds): once, `t` seconds in, print one
/// line per live archetype — entity count plus its signal components, largest first — and a machine-readable
/// summary. The "what IS the entity count made of" instrument: the standing HUD reads tens of
/// thousands of entities, and every per-frame cost that scales with *residency* (0362's
/// change-tick sweeps, transform propagation, render extraction) is only attributable once
/// residency itself has names. Born with the cost-ledger campaign.
/// `WOW_ENTITY_CENSUS=<secs>` (real seconds): once, one line per archetype, largest first, with
/// its entity count and signal components, then a summary.
pub(crate) struct EntityCensusPlugin;
impl Plugin for EntityCensusPlugin {
@@ -117,7 +105,7 @@ impl Plugin for EntityCensusPlugin {
}
}
/// [`EntityCensusPlugin`] state: the fire time and the once-latch.
/// [`EntityCensusPlugin`] state.
#[derive(Resource)]
struct EntityCensus {
at: f32,
@@ -127,15 +115,12 @@ struct EntityCensus {
/// Archetype lines the census prints; everything smaller folds into the summary's `other_n`.
const ENTITY_CENSUS_ROWS: usize = 60;
/// Signal components shown per archetype line — enough to name what the entities are without
/// drowning the line in a 30-component render archetype.
/// Signal components shown per archetype line.
const ENTITY_CENSUS_COMPS: usize = 14;
fn fire_entity_census(world: &mut World) {
{
// REAL seconds, not virtual: the census is timed to compose with `WOW_LIVE_FPS_AT`
// (also real), and virtual time lags real by the load stalls — a virtual-timed one-shot
// scheduled "just before sampling" fires after the probe has already exited.
// Real seconds, to compose with `WOW_LIVE_FPS_AT`; virtual time lags by the load stalls.
let time = world.resource::<Time<bevy::time::Real>>().elapsed_secs();
let probe = world.resource::<EntityCensus>();
if probe.fired || time < probe.at {
@@ -144,12 +129,7 @@ fn fire_entity_census(world: &mut World) {
}
world.resource_mut::<EntityCensus>().fired = true;
// The anchor split (0732 slice A's premise check). `RigAnchor` leaves are the single largest
// archetype in the scene — 53 % of all entities — and slice A's claim is that most of them ride
// nothing. "Rides nothing" is directly observable: an anchor whose entity has no `Children` is
// hosting no attachment, no emitter, no ribbon, no card. Counted here rather than inferred from
// the model's bone sources, because the model says what COULD attach and the world says what
// DID.
// `RigAnchor`s with no `Children` host nothing: no attachment, emitter, ribbon or card.
{
let mut q = world.query_filtered::<Option<&bevy::prelude::Children>, bevy::prelude::With<benilla_world::rig_anim::RigAnchor>>();
let (mut total, mut childless) = (0u32, 0u32);
@@ -178,14 +158,12 @@ fn fire_entity_census(world: &mut World) {
.filter_map(|id| components.get_info(*id))
.map(|c| c.name().shortname().to_string())
.collect();
// Whether this archetype sits in the render-visibility population: bevy's
// `check_visibility` sweeps every `ViewVisibility` row once PER ACTIVE CAMERA,
// so `vis=y` rows are the ones a second camera (booth) re-bills the frame for.
// `vis=y`: bevy's `check_visibility` sweeps these rows once per active camera.
let in_vis_population = full.iter().any(|n| n == "ViewVisibility");
let signal: Vec<String> = full
.iter()
.filter(|n| {
// Drop the ubiquitous plumbing components — the signal is the rest.
// Drop the plumbing components.
!matches!(
n.as_str(),
"Transform"
@@ -199,9 +177,7 @@ fn fire_entity_census(world: &mut World) {
})
.cloned()
.collect();
// A bare transform node has no signal left after the filter — and two such
// archetypes differing only in plumbing (Children vs not) would print as identical
// rows. For those, the plumbing IS the signal: print the full list.
// With one signal component or none, the plumbing tells archetypes apart: print it all.
let names = if signal.len() <= 1 { full } else { signal };
let shown = names.len().min(ENTITY_CENSUS_COMPS);
let more = names.len() - shown;
File diff suppressed because it is too large Load Diff
+36 -54
View File
@@ -1,21 +1,14 @@
//! The `waterfx` capture viewer — the foam **instrument** (decision 0022: see it before tuning
//! it). A server-less synthetic rig: one dummy wading unit over a synthetic water footprint
//! (a real 4.1667-yd wet-cell lattice, so patch building and bank clipping run for real) with a
//! flat backdrop for contrast, driven through the NORMAL emitter path — nothing here bypasses the
//! shipped systems; the rig only supplies a unit, water, and motion.
//! The `waterfx` capture viewer for water foam: a server-less rig of one wading dummy over a
//! synthetic 4.1667-yd wet-cell lattice with a flat backdrop, driven through the shipped emitter.
//!
//! `WOW_CAPTURE=waterfx` + knobs: `WOW_WFX_MODE` (`ring`|`wake`|`turn`), `WOW_WFX_SPEED` (yd/s),
//! `WOW_WFX_HEAD` (wake heading, WoW degrees, 0 = +X — point it along a real bank),
//! `WOW_WFX_AGE` (s of motion before the shot), `WOW_WFX_DEPTH` (yd below the surface; > ~0.8
//! also exercises the step-in one-shot), camera `WOW_WFX_AZ`/`EL`/`DIST`. Not a golden scenario —
//! output depends on the knobs.
//! `WOW_CAPTURE=waterfx` with knobs: `WOW_WFX_MODE` (`ring`|`wake`|`turn`), `WOW_WFX_SPEED` (yd/s),
//! `WOW_WFX_HEAD` (wake heading, WoW degrees, 0 = +X), `WOW_WFX_AGE` (seconds of motion before the
//! shot), `WOW_WFX_DEPTH` (yd below the surface; past ~0.8 also fires the step-in one-shot), and
//! camera `WOW_WFX_AZ`/`EL`/`DIST`. Not a golden scenario.
//!
//! **`WOW_WFX_AT=x,y,z` wades the dummy in the REAL world instead** (`z` = the liquid surface
//! height there, which `benilla-formats --example water_here` prints). The synthetic lattice and
//! backdrop stand down and the rig wades in the streamed ADT/WMO liquid at that pin — which is the
//! only way to see the two things a synthetic square of water cannot show: how a patch **clips at a
//! real bank**, and how it **sorts against the neighbouring water chunks** (B348 — one square of
//! water has no neighbour to be painted over by). `WOW_MAP` picks the map.
//! `WOW_WFX_AT=x,y,z` wades the dummy in the real streamed liquid instead (`z` the surface height,
//! which `benilla-formats --example water_here` prints; `WOW_MAP` picks the map), the only rig that
//! shows bank clipping and sorting against neighbouring water chunks.
use bevy::prelude::*;
@@ -29,34 +22,32 @@ use benilla_world::liquid::{FoamPatch, WaterChunkInfo};
/// Which foam behaviour the viewer exercises.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum WfxMode {
/// Standing unit → the pulsing RING.
/// Standing unit: the pulsing ring.
Ring,
/// Unit translating in WoW +X → the trailing WAKE (ends at the rig centre).
/// Unit translating along the heading: the trailing wake, ending at the rig centre.
Wake,
/// Unit turning in place → full-size RINGs (the `& 0x30` state).
/// Unit turning in place: full-size rings (the `& 0x30` state).
Turn,
}
/// The `waterfx` viewer request (built by [`crate::capture`] from env knobs). Its presence turns
/// on the rig systems below.
/// The `waterfx` viewer request, built by [`crate::capture`] from the knobs; its presence turns on
/// the rig.
#[derive(Resource)]
pub(crate) struct WaterFxView {
pub(crate) mode: WfxMode,
/// Translation speed for [`WfxMode::Wake`] (yd/s, along [`Self::heading`]).
pub(crate) speed: f32,
/// Which way a [`WfxMode::Wake`] walks, as a WoW yaw in radians (0 = +X). No shoreline in the
/// game is axis-aligned, so a wake that can only run along +X can never be laid **along** a
/// bank — the one arrangement where a trail's whole length ties against the waterline at once.
/// Which way a [`WfxMode::Wake`] walks, as a WoW yaw in radians (0 = +X), to lay it along a
/// bank.
pub(crate) heading: f32,
/// Seconds the unit moves/stands before the shot (foam accumulates).
/// Seconds the unit moves or stands before the shot.
pub(crate) age: f32,
/// Rig centre in raw WoW coords `(x, y, surface_z)` — where the unit ends up at shot time.
/// Rig centre in WoW coords `(x, y, surface_z)`, where the unit is at shot time.
pub(crate) center: [f32; 3],
/// Feet depth below the surface (yd) — must land inside the wading gate.
/// Feet depth below the surface (yd); must land inside the wading gate.
pub(crate) depth: f32,
/// Wade in the **real** streamed liquid at [`Self::center`] (`WOW_WFX_AT`) rather than over the
/// synthetic lattice: no backdrop, no fixture water, just the dummy. A real shoreline is the
/// only rig that exercises bank clipping and multi-chunk sorting.
/// Wade in the real streamed liquid at [`Self::center`] (`WOW_WFX_AT`): no backdrop, no
/// fixture water.
pub(crate) live: bool,
}
@@ -64,12 +55,11 @@ pub(crate) struct WaterFxView {
#[derive(Component)]
pub(crate) struct WaterFxDummy;
/// The MCLQ wet-cell edge (yd) — the synthetic lattice mirrors the real liquid granularity.
/// The MCLQ wet-cell edge (yd).
const CELL: f32 = 33.333_332 / 8.0;
/// Once the capture harness arms the scene, stand up the rig: a dark backdrop plane just under
/// the surface, a synthetic wet-cell lattice (12×12 cells ≈ 50 yd square), and one wading dummy.
/// Sets [`FxViewState::attached_at`] as the age-clock zero.
/// Once the scene is armed, stands up the backdrop, a 12x12-cell lattice (~50 yd square) and the
/// dummy, and sets [`FxViewState::attached_at`] as the age clock's zero.
pub(crate) fn spawn(
mut commands: Commands,
view: Option<Res<WaterFxView>>,
@@ -94,8 +84,7 @@ pub(crate) fn spawn(
return;
}
// A big flat mid-gray "water body" backdrop 0.15 yd under the surface, so the additive foam
// reads against a stable tone (the real liquid shader is beside the point here).
// A flat backdrop 0.15 yd under the surface, so the additive foam reads against a stable tone.
commands.spawn((
WaterFxDummy,
Mesh3d(meshes.add(Plane3d::default().mesh().size(120.0, 120.0).build())),
@@ -107,9 +96,7 @@ pub(crate) fn spawn(
Transform::from_translation(wow_to_bevy([cx, cy, surf - 0.15])),
));
// The synthetic water chunk: a 12×12 grid of wet cells centred on the rig, flat at the surface
// — the same components the terrain streamer attaches to real liquid, so the foam emitter and
// patch builder run the shipped path.
// The components the terrain streamer attaches to real liquid, so the shipped path runs.
let n = 12;
let half = n as f32 * CELL * 0.5;
let (x0, y0) = (cx - half, cy - half);
@@ -122,8 +109,7 @@ pub(crate) fn spawn(
commands.spawn((
WaterFxDummy,
WaterChunkInfo::new(
// The fixture stands in for an outdoor lake: ADT-sourced still water, so the
// `liquid_at` delegation answers it for an outdoors subject.
// An outdoor lake: ADT still water, which `liquid_at` answers outdoors.
benilla_world::liquid::LiquidSource::AdtChunk,
benilla_formats::LiquidKind::Still,
[n + 1, n + 1],
@@ -149,9 +135,8 @@ pub(crate) fn spawn(
);
}
/// The wading dummy: a plain streamed-unit shape (no display id, visual pre-attached so the entity
/// subsystem never gives it a fallback cube over the foam). A wake starts far enough back that its
/// trail ENDS at the rig centre after `age` seconds.
/// The wading dummy: a streamed unit with no display and its visual pre-attached, so it gets no
/// fallback cube. A wake starts far enough back to end at the rig centre after `age` seconds.
fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, surf: f32) {
let (start_x, start_y) = if view.mode == WfxMode::Wake {
let back = view.speed * view.age;
@@ -169,16 +154,13 @@ fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, su
display_id: None,
scale: 1.0,
},
// Stated at the spawn, not left to `entities::publish_world_units`: the reconciler runs
// between the wire drain and the rest of the frame, and this fixture spawns in
// `WorldStage::Present` — so waiting for it would cost the rig its first frame of foam
// and shift every ripple's age by one step for the whole aged capture.
// Set here, not by `entities::publish_world_units`, which runs before this
// `WorldStage::Present` spawn and would cost the rig its first frame of foam.
benilla_world::world_unit::WorldUnit {
wades: true,
scale: 1.0,
height: crate::entities::CollisionHeight::default().0,
// The fixture is a foam rig, not a scene body: it has no model box and the capture is
// outdoors, where the exterior cull stands down anyway. `None` = don't decide.
// No model box; outdoors the exterior cull stands down anyway.
bound: None,
},
crate::entities::VisualAttached,
@@ -186,8 +168,8 @@ fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, su
));
}
/// Drive the dummy each frame: translate (wake) or spin (turn) until the age elapses; the foam
/// emitter reads the motion through its normal velocity/yaw proxies.
/// Moves the dummy (wake) or spins it (turn) until the age elapses; the emitter reads the motion
/// through its normal velocity and yaw proxies.
pub(crate) fn drive(
view: Option<Res<WaterFxView>>,
state: Option<Res<FxViewState>>,
@@ -206,8 +188,8 @@ pub(crate) fn drive(
for mut t in &mut units {
match view.mode {
WfxMode::Wake => {
// bevy = (−wow.y, wow.z, −wow.x), so a WoW heading (cos h, sin h) walks the dummy
// along Bevy (−sin h, 0, −cos h).
// bevy = (-wow.y, wow.z, -wow.x), so WoW heading (cos h, sin h) is Bevy
// (-sin h, 0, -cos h).
let step = view.speed * time.delta_secs();
t.translation.x -= step * view.heading.sin();
t.translation.z -= step * view.heading.cos();
+71 -192
View File
@@ -1,21 +1,6 @@
//! The character-creation screen — the v1 glue overlay (decision 0423, phase 4).
//!
//! A disposable Bevy-UI screen (the same register as [`crate::char_select`], not GlueXML — the
//! faithful glue arc is 0193's) arranged like the reference client's `CharacterCreate.xml` and
//! driving the live preview booth ([`crate::portrait`]'s `"create"` slot): the left tower holds the
//! faction-bannered race grid, the gender pair, the per-race class grid (only the CharBaseInfo-valid
//! classes, like the ref), the five appearance dials (ranges data-derived per (race, sex) —
//! [`crate::entities::CharCreate`]; labels per-race via the ChrRaces customization tokens), and
//! Randomize; the right stack quotes the GlueStrings faction/race/class paragraphs; the model floats
//! center over the page (transparent booth); name + Accept/Back sit along the bottom. The real art,
//! captions, and click sounds come from the player's own client data ([`art`]'s `GlueArt`,
//! [`crate::glue_strings::GlueStrings`], [`crate::sound::GlueSound`]) — never embedded. Every
//! `SMSG_CHAR_CREATE` result maps to its 1.12 GlueStrings text in the status line; a success
//! re-enums (phase 1) and returns to select with the new row armed.
//!
//! The module is split by concern: [`art`] (client-data art + the frozen tables), [`parts`] (the
//! component vocabulary), [`widgets`] (the glue button shapes), [`screen`] (the authored layout),
//! [`refresh`] (the systems driving it), and this file (state, input, wire).
//! The character-creation screen: a Bevy-UI overlay laid out like the 1.12 `CharacterCreate.xml`,
//! driving the `"create"` preview booth, with art, captions and sounds read from the player's
//! install. This file holds the selection state, input and the wire.
mod panels;
mod parts;
@@ -38,24 +23,19 @@ use crate::net::{CharActionResultMessage, CharPick, CharRequest};
use crate::portrait::{CreateLook, GlueLook, GluePreview};
use crate::sound::GlueSound;
/// Alliance / Horde race columns, top to bottom — the order the reference screen shows
/// (director's screenshot, 2026-07-19). `CharacterCreate.lua`'s `CharacterCreateEnumerateRaces`
/// fills `CharacterCreateRaceButton1..8` in the order the engine's `GetAvailableRaces()` returns,
/// and `CharacterCreate.xml` chains buttons 1–4 down column A from (33,−68) with button 5 anchored
/// right of button 1 (+46) and 6–8 chained below it — so the flat engine order fills Alliance then
/// Horde, ascending race id within each. (`RACE_ICON_TCOORDS` is a name→UV lookup; its literal table
/// order never reaches layout — reading it as the button order is what got this wrong before.)
/// Alliance and Horde race columns, top to bottom: `CharacterCreateEnumerateRaces` fills buttons
/// 1..8 in `GetAvailableRaces()` order and `CharacterCreate.xml` chains 1-4 down the first column,
/// 5-8 down the second, so Alliance then Horde, ascending race id. `RACE_ICON_TCOORDS`'s table
/// order is a name-to-UV lookup and never the layout order.
///
/// `pub(crate)` on the Alliance half because it is also the race→side split
/// `ui_unit::race_faction_group` answers `UnitFactionGroup("player")` with during world entry —
/// one home for the mapping, pinned by [`tests::race_columns_match_the_reference_screen`], rather
/// than a second copy that can disagree with this one.
/// The Alliance half is also the race-to-side split `ui_unit::race_faction_group` answers
/// `UnitFactionGroup("player")` with.
pub(crate) const ALLIANCE: [u8; 4] = [1, 3, 4, 7]; // Human, Dwarf, Night Elf, Gnome
const HORDE: [u8; 4] = [2, 5, 6, 8]; // Orc, Scourge, Tauren, Troll
/// The ref's initial model facing (`SetCharacterCreateFacing(-15)`), reset on every race switch.
/// The reference's initial facing (`SetCharacterCreateFacing(-15)`), reset on every race switch.
const INITIAL_FACING: f32 = -15.0 * std::f32::consts::PI / 180.0;
/// The character-creation subsystem (decision 0423): the screen + its selection state.
/// The character-creation screen and its selection state.
pub(crate) struct CharCreatePlugin;
impl Plugin for CharCreatePlugin {
@@ -89,10 +69,8 @@ impl Plugin for CharCreatePlugin {
}
}
/// The end-to-end create instrument (`WOW_CHARCREATE_NAME=<name>`, decision 0423): a few seconds
/// after the create screen is up, fill the name and fire Create — so the whole screen → wire →
/// server → result → back-to-select path is verifiable headlessly (pair with `WOW_CHARCREATE_SHOT=1`
/// to reach the screen). Inert without the env; fires once.
/// `WOW_CHARCREATE_NAME=<name>`: a few seconds after the screen is up, fill the name and fire
/// Create once, exercising the whole create path unattended (pair with `WOW_CHARCREATE_SHOT=1`).
fn debug_auto_create(
mut sel: ResMut<CreateSelection>,
pick: Res<CharPick>,
@@ -110,7 +88,7 @@ fn debug_auto_create(
let now = time.elapsed_secs();
let start = *armed_at.get_or_insert(now);
if now - start < 6.0 {
return; // let the model settle + the socket park
return; // let the model settle and the socket park
}
sel.name.set_text(&name);
sel.creating = true;
@@ -119,8 +97,7 @@ fn debug_auto_create(
info!("char create: auto-create fired for {:?}", sel.name.text);
}
/// The screen-shot instrument (`WOW_CHARCREATE_SHOT=1`, decision 0423): jump to the create screen a
/// few seconds after boot so a live shot / eyeball reaches it without a click. Inert without the env.
/// `WOW_CHARCREATE_SHOT=1`: jump to the create screen a few seconds after boot, without a click.
fn debug_enter(
state: Res<State<ClientState>>,
mut next: ResMut<NextState<ClientState>>,
@@ -137,12 +114,8 @@ fn debug_enter(
}
}
/// The shot instrument's race/sex/class pick (`WOW_CHARCREATE_PICK="race,sex[,class]"`): applied
/// once as soon as the create screen is up (after its enter reset), so a probe shot can capture any
/// race's scene — the ref comparisons are per-race. The optional third field picks the class, which
/// selects the starting outfit the preview wears (decision 0527) — so an A/B of the same race at two
/// classes machine-checks the dressing path. An out-of-range class for the race is ignored (the
/// race's first class stands). Inert without the env.
/// `WOW_CHARCREATE_PICK="race,sex[,class]"`: applied once the screen is up, after its enter reset;
/// a class the race may not be is ignored.
fn debug_pick(
state: Res<State<ClientState>>,
catalog: Option<Res<CharCreate>>,
@@ -163,8 +136,6 @@ fn debug_pick(
let mut it = spec.split(',').map(|s| s.trim().parse::<u8>().ok());
sel.race = it.next().flatten().unwrap_or(1).max(1);
sel.sex = it.next().flatten().unwrap_or(0).min(1);
// `clamp` seats the race's first class; the optional third field overrides it, honored only if
// the race may actually be that class.
sel.clamp(catalog.as_deref());
if let Some(class) = it.next().flatten() {
if race_classes(catalog.as_deref(), sel.race).contains(&class) {
@@ -176,10 +147,8 @@ fn debug_pick(
);
}
}
// The appearance dials (`WOW_CHARCREATE_DIALS="skin,face,hairStyle,hairColor,facialHair"`):
// each field optional, a missing/blank one leaves that dial at 0. Every value is clamped into
// the (race, sex)'s real range, so a probe can name a specific look — e.g. the bald + bearded
// combination that leaves an orc/gnome male's facial hair without a hair texture.
// `WOW_CHARCREATE_DIALS="skin,face,hairStyle,hairColor,facialHair"`: each field optional
// (missing leaves 0), each clamped into the (race, sex)'s range.
if let Ok(spec) = std::env::var("WOW_CHARCREATE_DIALS") {
let counts = dial_counts(catalog.as_deref(), sel.race, sel.sex);
for (i, field) in spec.split(',').take(5).enumerate() {
@@ -205,10 +174,8 @@ fn debug_pick(
*done = true;
}
/// The shot writer (`WOW_CHARCREATE_SHOT_OUT=<path>`): once the create screen has been up a few
/// seconds (art + model settled), write one PNG of the window via Bevy's own framebuffer readback —
/// so an agent run can machine-check the screen's geometry without macOS screen-recording
/// permission. Pairs with `WOW_CHARCREATE_SHOT=1`; inert without the env.
/// `WOW_CHARCREATE_SHOT_OUT=<path>`: once the screen has settled, write one PNG of the window
/// through Bevy's framebuffer readback. Pairs with `WOW_CHARCREATE_SHOT=1`.
fn debug_shot(
mut commands: Commands,
time: Res<Time>,
@@ -236,27 +203,22 @@ fn debug_shot(
// ── The selection state ──────────────────────────────────────────────────────────────────────────
/// What the create screen currently has selected. The five dials are `[skin, face, hairStyle,
/// hairColor, facialHair]` indices; the ranges come from [`CharCreate`], and are re-clamped whenever
/// race/gender changes (so a dial never points past the new race's range). `class` reaches the booth
/// too — it picks the starting outfit the preview wears (decision 0527).
/// What the create screen has selected. The dials are `[skin, face, hairStyle, hairColor,
/// facialHair]` indices into [`CharCreate`]'s ranges, re-clamped on every race or gender change;
/// `class` picks the starting outfit the preview wears.
#[derive(Resource, Default)]
pub(crate) struct CreateSelection {
race: u8,
sex: u8,
class: u8,
dials: [u8; 5],
/// The typed name — a real [`EditBoxState`] (decision 0704), so it has the caret, selection,
/// Ctrl+A and clipboard the chat box has. Letters-only and the 12-cap are enforced by the
/// shared feed, on pasted text as well as typed.
/// The typed name; the shared feed enforces letters only and the 12-character cap, pasted too.
name: EditBoxState,
/// A create is in flight (waiting on `SMSG_CHAR_CREATE`) — the Create button is disarmed and the
/// status shows progress.
/// Waiting on `SMSG_CHAR_CREATE`: Create is disarmed.
creating: bool,
}
/// The five dial counts for a (race, sex), or `[1; 5]` when the catalog is missing (so the UI still
/// renders, degenerate).
/// The five dial counts for a (race, sex), or `[1; 5]` without the catalog.
fn dial_counts(catalog: Option<&CharCreate>, race: u8, sex: u8) -> [u8; 5] {
catalog
.and_then(|c| c.0.ranges(race, sex))
@@ -265,7 +227,7 @@ fn dial_counts(catalog: Option<&CharCreate>, race: u8, sex: u8) -> [u8; 5] {
}
impl CreateSelection {
/// Reset to a valid default for the catalog (Human, male, its first class, dials 0).
/// Reset to Human, male, its first class, dials 0.
fn reset(&mut self, catalog: Option<&CharCreate>) {
self.race = 1;
self.sex = 0;
@@ -277,7 +239,7 @@ impl CreateSelection {
self.creating = false;
}
/// Re-clamp class + dials into the current (race, sex)'s valid ranges — after a race/gender change.
/// Re-clamp class and dials into the current (race, sex)'s valid ranges.
fn clamp(&mut self, catalog: Option<&CharCreate>) {
if let Some(c) = catalog {
if !c.0.allows(self.race, self.class) {
@@ -294,8 +256,7 @@ impl CreateSelection {
}
}
/// The look to show in the booth: race/gender/class + appearance. Class dresses the preview in
/// the (race, class, sex) starting outfit (decision 0527), so a class change re-bakes the model.
/// The booth's look; the class dresses the (race, class, sex) starting outfit.
fn look(&self) -> CreateLook {
CreateLook {
race: self.race,
@@ -309,7 +270,6 @@ impl CreateSelection {
}
}
/// The wire request for the current selection.
fn request(&self) -> CharCreateReq {
CharCreateReq {
name: self.name.text.clone(),
@@ -325,36 +285,35 @@ impl CreateSelection {
}
}
/// One clickable control on the screen — a single component so one query dispatches every button.
/// One clickable control on the screen.
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
enum CreateAction {
Race(u8),
Gender(u8),
/// A class-grid slot (index into the selected race's valid-class list — the ref enumerates only
/// the classes the race may be, so the slot→class mapping shifts per race).
/// An index into the race's valid classes: the reference lists only those, so the slot's class
/// shifts per race.
ClassSlot(u8),
/// A dial spinner arrow: dial index 0..5, direction ±1.
/// Dial index 0..5, direction ±1.
Dial(u8, i8),
Randomize,
Create,
Back,
/// Hold-to-rotate (the ref's rotate buttons, ±2°/frame).
/// Hold to rotate, like the reference's rotate buttons.
RotateLeft,
RotateRight,
/// The model pane (drag to rotate — no click action, but it carries the tag for the drag test).
/// The model pane: drag to rotate, no click action.
Model,
}
/// The classes the selected race may be, ascending class id (the CharBaseInfo file order — the
/// ref's `GetClassesForRace` enumeration).
/// The classes the race may be, ascending class id: CharBaseInfo order, as `GetClassesForRace`
/// enumerates them.
fn race_classes(catalog: Option<&CharCreate>, race: u8) -> Vec<u8> {
catalog
.map(|c| c.0.classes_for_race(race))
.unwrap_or_else(|| vec![1, 2, 3, 4, 5, 7, 8, 9, 11])
}
/// A class id's GlueStrings fileString (`CLASS_<FILE>`, `CLASS_ICON_TCOORDS` key) — a frozen enum
/// of the build.
/// A class id's file string, the `CLASS_ICON_TCOORDS` key.
fn class_file(class: u8) -> &'static str {
match class {
1 => "WARRIOR",
@@ -372,13 +331,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 — `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
/// that resource's change detection every frame and defeat `refresh_dynamic`'s `is_changed` gate,
/// re-running the whole dial/panel/icon refresh 60× a second.
/// Clicks, name typing, Enter and Escape. The name caret blinks at the reference's `blinkSpeed`,
/// 0.5 s (`0x77a790`).
fn create_input(
buttons: Query<(Entity, &CreateAction)>,
clicks: Res<crate::glue::GlueClicks>,
@@ -386,7 +340,7 @@ fn create_input(
keys: Res<ButtonInput<KeyCode>>,
catalog: Option<Res<CharCreate>>,
mut sel: ResMut<CreateSelection>,
// The host pasteboard + the window handle its Wayland backend needs (decision 0702).
// The host pasteboard and the window handle its Wayland backend needs.
mut clipboard: NonSendMut<HostClipboard>,
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
time: Res<Time>,
@@ -404,17 +358,15 @@ fn create_input(
let mut changed_look = false;
let mut do_create = false;
// Every control on this screen is a Button, and a Button fires on the RELEASE, over the
// button that took the press (1533, `crate::glue::glue_clicks`). The name box needs no
// press-to-focus: the screen has one field and it is always focused while up.
// A Button fires on the release over the button that took the press (`glue::glue_clicks`).
for (entity, action) in &buttons {
if !clicks.hit(entity) {
continue;
}
match *action {
CreateAction::Race(r) => {
// The ref plays the click always, switches only on a real change — and a switch
// resets the class to the race's first and the facing to −15°.
// The reference always clicks; a real switch resets the class to the race's first
// and the facing to -15°.
sounds.write(GlueSound("gsCharacterCreationClass"));
if sel.race != r {
sel.race = r;
@@ -433,9 +385,7 @@ fn create_input(
}
}
CreateAction::ClassSlot(slot) => {
// The ref plays the click always, and re-dresses the model only on a real change:
// `SelectClass` (`0x470f50`) → `cc_apply_sections` re-applies equipment, because the
// class picks the starting outfit the preview wears (decision 0527).
// The reference always clicks and re-dresses only on a real change (`0x470f50`).
if let Some(&class) = race_classes(cat, sel.race).get(slot as usize) {
sounds.write(GlueSound("gsCharacterCreationClass"));
if sel.class != class {
@@ -463,7 +413,6 @@ fn create_input(
}
}
// Name typing + Enter/Esc.
for ev in keyboard.read() {
if ev.state != ButtonState::Pressed {
continue;
@@ -506,8 +455,7 @@ fn cycle_dial(sel: &mut CreateSelection, catalog: Option<&CharCreate>, dial: usi
sel.dials[dial] = (cur + dir as i32).rem_euclid(count) as u8;
}
/// Set every dial to a random valid index (a tiny xorshift, seeded off a per-run counter — the
/// screen has no need for a real RNG dependency; captures never hit Randomize).
/// Set every dial to a random valid index, from a small xorshift.
fn randomize(sel: &mut CreateSelection, catalog: Option<&CharCreate>, rng: &mut u64) {
let counts = dial_counts(catalog, sel.race, sel.sex);
for (d, &n) in sel.dials.iter_mut().zip(&counts) {
@@ -519,10 +467,7 @@ fn randomize(sel: &mut CreateSelection, catalog: Option<&CharCreate>, rng: &mut
}
}
/// Paint the name box from its [`EditBoxState`] — the display segments, the selection highlight and
/// the caret at the cursor — through the shared [`crate::glue::widgets::paint_glue_field`], so it
/// draws exactly like the login boxes (decision 0704). Only the five name-box row items carry a
/// `GlueFieldPart`, so requiring it is enough to pick them out of every other `DynText`.
/// Paint the name box through the shared `paint_glue_field`, so it draws like the login boxes.
fn refresh_name_box(
sel: Res<CreateSelection>,
mut parts: Query<(
@@ -531,19 +476,12 @@ fn refresh_name_box(
&mut Visibility,
)>,
) {
// One field, no focus model: it is focused whenever the screen is up.
crate::glue::widgets::paint_glue_field(&sel.name, true, parts.iter_mut());
}
/// Rotate the preview: drag on the model pane (the ref's full-frame mouse rotation: `facing +=
/// Δcursor·CHARACTER_ROTATION_CONSTANT` — dragging right *increases* the facing, same sign as the
/// right rotate button), or hold a rotate button (the ref's ±2°-per-frame
/// `RotateLeft/Right_OnUpdate`; left decrements the facing).
///
/// The drag constant is the select screen's, because in the reference it is literally the same
/// number: `CHARACTER_ROTATION_CONSTANT = 0.6` is declared once in `CharacterSelect.lua` and read
/// by both screens' `OnUpdate`. This screen carried a bare `0.01` rad/px (0.573°/px) instead — a
/// 4.5 % drift from the screen next door, against a comment that already claimed 0.6 (1533).
/// Rotate the preview by dragging the model pane (dragging right increases the facing) or holding
/// a rotate button (left decrements it). The drag uses the select screen's constant: the reference
/// declares `CHARACTER_ROTATION_CONSTANT = 0.6` once in `CharacterSelect.lua` for both screens.
fn rotate_model(
panes: Query<(&Interaction, &CreateAction)>,
motion: Res<AccumulatedMouseMotion>,
@@ -591,10 +529,8 @@ fn create_result(
char_result_text(&strings, msg.code)
);
if msg.code == benilla_protocol::messages::CHAR_CREATE_SUCCESS {
// The fresh roster already arrived (`net::io` re-enumerates and emits it BEFORE the
// result), so `note_created` selects the new row against the list already in hand —
// arming a flag for "the next roster update" waited for a message that never comes
// again, and the select screen came back on the old row (B119).
// `net::io` re-enumerates and emits the fresh roster before the result, so the new
// row is selected against the list already in hand; no later roster update comes.
roster.note_created(sel.name.text.clone());
next.set(ClientState::CharSelect);
} else if let Ok(mut text) = status.single_mut() {
@@ -603,26 +539,10 @@ fn create_result(
}
}
/// A `SMSG_CHAR_CREATE` result byte → its **GlueStrings key**, resolved off the player's own
/// `GlueStrings.lua` with a built-in caption as the fallback — `login::world_refusal_text`'s shape,
/// and the reference's own (a key first, a literal only when the chain lacks it).
///
/// The codes are vmangos's `ResponseCodes`, anchored at `CHAR_CREATE_SUCCESS = 0x2E`; every key
/// below was derived by matching our old text against the shipped file rather than by name, then
/// corrected where the *semantics* disagreed with the match. Three things that hiding behind
/// literals had concealed (decision 2045):
///
/// - **`0x36` was missing a sentence.** Our copy of `CHAR_CREATE_SERVER_QUEUE` stopped at
/// "…temporarily disabled." where the shipped string continues "Please try again during off peak
/// hours." A re-typed string is a string nobody diffed.
/// - **`0x50` was invented.** "You cannot use consecutive spaces in a name" has no 1.12
/// counterpart — there is no `CHAR_NAME_CONSECUTIVE_SPACES` in this chain — so it takes the
/// default arm, `CHAR_CREATE_INVALID_NAME`, exactly as any code the table does not name does.
/// Composing a better sentence for a code the client has no string for is what 2035 was about.
/// - **`0x4B` named the wrong key.** It resolved `CHAR_CREATE_NAME_IN_USE` because that key's text
/// *equals* `CHAR_NAME_RESERVED`'s in enUS — "That name is unavailable" — but the code is the
/// reserved-name one. Identical text, different key: a locale that words them apart would have
/// shown the wrong sentence, and no amount of text comparison could ever have found it.
/// A `SMSG_CHAR_CREATE` result code (vmangos `ResponseCodes`, `CHAR_CREATE_SUCCESS = 0x2E`) to its
/// GlueStrings key, read off the player's chain, with a built-in caption only when the key is
/// missing. `0x50` has no 1.12 string and takes the default arm; `0x4B` is `CHAR_NAME_RESERVED`,
/// whose enUS text equals `CHAR_CREATE_NAME_IN_USE`'s.
fn char_result_text<'a>(strings: &'a GlueStrings, code: u8) -> &'a str {
let (key, fallback): (&str, &'a str) = match code {
0x2E => ("CHAR_CREATE_SUCCESS", "Character created"),
@@ -694,28 +614,22 @@ fn char_result_text<'a>(strings: &'a GlueStrings, code: u8) -> &'a str {
#[cfg(test)]
mod tests {
/// **Every char-create result resolves to the sentence 1.12 actually ships**, read off the
/// player's own chain — `GlueStrings.lua` with `GlueLocalization.lua`'s `Localize()` patch over
/// it, assembled by the loader's own helper so this cannot assert a sentence the running client
/// would not show (2052). The regression for three bugs that literals had hidden (decision
/// 2045). Skips without client data.
/// The chain is assembled by the loader's own helper, `GlueLocalization.lua`'s `Localize()`
/// over `GlueStrings.lua`, so this asserts what the running client shows.
#[test]
fn every_char_create_result_resolves_in_the_real_glue_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let strings = crate::glue_strings::table_from_chain(&mut chain);
// `0x36` used to stop at "…temporarily disabled."; the shipped string carries a second
// sentence, and a re-typed string is one nobody diffed.
// The shipped `0x36` string has a second sentence.
assert_eq!(
char_result_text(&strings, 0x36),
"This server is currently queued and new character creation is temporarily disabled. \
Please try again during off peak hours."
);
// `0x50` is INVENTED text: 1.12 has no `CHAR_NAME_CONSECUTIVE_SPACES`, so the code takes
// the default arm like any other the table does not name. If a chain ever *does* carry
// such a key this assert is what will say so.
// 1.12 has no `CHAR_NAME_CONSECUTIVE_SPACES`, so `0x50` takes the default arm.
assert_eq!(
char_result_text(&strings, 0x50),
char_result_text(&strings, 0xFE),
@@ -723,8 +637,6 @@ mod tests {
);
assert_eq!(char_result_text(&strings, 0xFE), "Invalid character name");
// Every named code must resolve to a REAL key — the fallback caption never showing is the
// whole point, since a fallback that matches hides a missing key forever.
for code in [
0x2Eu8, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x45, 0x46, 0x47, 0x48,
0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F,
@@ -734,20 +646,13 @@ mod tests {
}
}
/// **`0x4B` is the reserved-name code, and text alone can never prove it.**
///
/// It used to resolve `CHAR_CREATE_NAME_IN_USE`, whose enUS value is *identical* to
/// `CHAR_NAME_RESERVED`'s — "That name is unavailable" — so no comparison of the displayed
/// string could distinguish right from wrong. This asserts the key by *name* against the
/// shipped file, which is the only thing that can. A locale that words the two apart is
/// exactly the case this protects.
/// The two keys share their enUS text, so the key is asserted by name through a sentinel.
#[test]
fn the_reserved_name_code_names_the_reserved_key_not_the_in_use_one() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let map = crate::glue_strings::table_from_chain(&mut chain).into_map();
// Both keys exist and agree in enUS — which is why the mix-up was invisible.
let reserved = map.get("CHAR_NAME_RESERVED").expect("CHAR_NAME_RESERVED");
let in_use = map
.get("CHAR_CREATE_NAME_IN_USE")
@@ -757,39 +662,26 @@ mod tests {
"if these ever differ, the mix-up becomes visible"
);
// Prove we read the RESERVED one: a table with only that key altered must move 0x4B.
let mut probe = map.clone();
probe.insert("CHAR_NAME_RESERVED".into(), "RESERVED-SENTINEL".into());
let strings = GlueStrings::from_map(probe);
assert_eq!(char_result_text(&strings, 0x4B), "RESERVED-SENTINEL");
// …and 0x31, the genuine in-use code, must NOT have moved.
assert_eq!(char_result_text(&strings, 0x31), in_use.as_str());
}
use super::*;
/// The race grid's column order, pinned against the reference screen (director's screenshot,
/// 2026-07-19): Alliance reads Human · Dwarf · Night Elf · Gnome, Horde reads Orc · Scourge ·
/// Tauren · Troll. This is a *regression* test with history: the columns were previously ordered
/// off `RACE_ICON_TCOORDS`'s table order, which is a name→UV lookup that never reaches layout —
/// the real order is whatever `GetAvailableRaces()` enumerates into buttons 1–8, which the XML
/// chains 1–4 down column A and 5–8 down column B.
#[test]
fn race_columns_match_the_reference_screen() {
assert_eq!(ALLIANCE, [1, 3, 4, 7], "Human, Dwarf, Night Elf, Gnome");
assert_eq!(HORDE, [2, 5, 6, 8], "Orc, Scourge, Tauren, Troll");
// Together the columns are exactly the eight playable races, no repeats.
let mut all: Vec<u8> = ALLIANCE.iter().chain(&HORDE).copied().collect();
all.sort_unstable();
assert_eq!(all, (1..=8).collect::<Vec<u8>>());
// Each column ascends by race id — the engine's per-faction enumeration order.
assert!(ALLIANCE.windows(2).all(|w| w[0] < w[1]));
assert!(HORDE.windows(2).all(|w| w[0] < w[1]));
}
/// The booth look carries the class, so a class click re-dresses the model (decision 0527).
/// Guards the regression this test was written for: `CreateSelection.class` was set by the
/// class buttons but never reached `GluePreview`, so the starting outfit never changed.
#[test]
fn look_carries_the_class() {
let mut sel = CreateSelection {
@@ -799,7 +691,7 @@ mod tests {
..default()
};
let warrior = sel.look();
sel.class = 8; // mage — a different starting outfit (robe, not the recruit set)
sel.class = 8; // mage, a different starting outfit
let mage = sel.look();
assert_eq!(warrior.class, 1);
assert_eq!(mage.class, 8);
@@ -809,22 +701,10 @@ mod tests {
);
}
/// **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.
/// `SetCharacterRace`, `SetCharacterClass` and `SetCharacterGender` `LockHighlight()` the
/// chosen button (`CharacterCreate.lua`); with the template's `<CheckedTexture>` commented out,
/// that lock is the whole selected visual. Built with the real `icon_button`, which must insert
/// the `LockHighlight` the refresh query needs.
#[test]
fn the_chosen_icons_are_lock_highlighted() {
use crate::glue::art::GlueArt;
@@ -877,8 +757,7 @@ mod tests {
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.
// Human, female, mage: without a catalog `race_classes` is the full list, so slot 6.
.insert_resource(CreateSelection {
race: 1,
sex: 1,
@@ -905,11 +784,11 @@ mod tests {
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"
"mage is slot 6 of [1,2,3,4,5,7,8,9,11] — the mage icon itself must carry the lock, \
not sit unmarked"
);
// …and the selection moving takes the lock with it, rather than lighting a second icon.
// Moving the selection moves the lock, never lights a second icon.
app.world_mut().resource_mut::<CreateSelection>().class = 1; // warrior, slot 0
app.update();
let locked: Vec<CreateAction> = app
+14 -23
View File
@@ -1,9 +1,6 @@
//! The create screen's right info stack (`CharacterCreateCharacterFaction/Race/Class`, 240 wide at
//! TOPRIGHT (−20,−20), heights 160/260/210, 10 apart): each a `TextPanel-Border` backdrop whose bg
//! tints with the faction, the header icon overhanging the corner, the scrollable title + body +
//! gold ability lines — and the `GlueScrollFrameTemplate` scrollbar along the right edge (16² arrow
//! buttons above/below a knob track, the CharacterCreate decorative track art behind it, everything
//! hidden while the panel has nothing to scroll — the ref's `scrollBarHideable`).
//! The create screen's right info stack, `CharacterCreateCharacterFaction/Race/Class`: each a
//! faction-tinted `TextPanel-Border` backdrop with a header icon, scrollable text and a
//! `GlueScrollFrameTemplate` scrollbar.
use bevy::prelude::*;
use bevy::ui::FocusPolicy;
@@ -55,8 +52,7 @@ pub(super) fn right_stack(
}
panel.with_children(|panel| {
if framed {
// The backdrop: tiled `UI-Tooltip-Background` inset (8,4,4,8) under the
// untinted `TextPanel-Border` (the ref tints only the bg).
// The reference tints only the bg, never the `TextPanel-Border`.
panel.spawn((
DynTint::BoxFill,
tiled_bg_node(
@@ -81,9 +77,8 @@ pub(super) fn right_stack(
Color::WHITE,
);
}
// The header icon, overhanging the top-left corner (48² at (−3, −8)) — ABOVE
// the scroll content: the ref's IconFrame `OnLoad` bumps its frame level +1,
// so scrolled text slides UNDER the icon, never over it.
// Above the scroll content: the reference's IconFrame `OnLoad` raises its
// frame level by 1, so scrolled text slides under the icon.
panel.spawn((
DynIcon::Info(kind),
ImageNode {
@@ -93,7 +88,6 @@ pub(super) fn right_stack(
ZIndex(1),
abs(s, -3.0, -8.0, 48.0, 48.0),
));
// The scrollable content (the ref's GlueScrollFrame, 190 wide at (17,−10)).
let scroll = panel
.spawn((
InfoScroll,
@@ -146,8 +140,7 @@ pub(super) fn right_stack(
font,
s,
);
// The racial abilities under the race paragraph, gold — the ref's
// separate `CharacterCreateRaceAbilityText` (`GlueFontNormalSmall`).
// The reference's separate gold `CharacterCreateRaceAbilityText`.
if kind == InfoKind::Race {
outlined_text(
inner,
@@ -177,10 +170,8 @@ pub(super) fn right_stack(
});
}
/// One panel's scrollbar (`GlueScrollFrameTemplate` resolved to panel coordinates): the decorative
/// track art behind, then the 16-wide slider column 6px right of the scroll frame — up button,
/// knob track, down button. Every piece carries [`ScrollHides`] (the whole bar vanishes while the
/// panel fits its text, like the ref's `scrollBarHideable` frames).
/// One panel's scrollbar, `GlueScrollFrameTemplate` in panel coordinates; the whole bar hides
/// while the panel fits its text, like the reference's `scrollBarHideable`.
fn scrollbar(
panel: &mut ChildSpawnerCommands,
sc: &ScrollArt,
@@ -188,11 +179,11 @@ fn scrollbar(
panel_h: f32,
s: f32,
) {
// The frame is inset 10 top/bottom; the slider is inset 16 more for the buttons.
// The frame is inset 10 top and bottom; the slider 16 more for the buttons.
let bar_h = panel_h - 20.0;
let track_h = bar_h - 32.0;
// `UI-CharacterCreate-ScrollBar-Top` (32×128 at frame TOPRIGHT (−3,4)) and the ClassTrainer
// strip (30×123 at frame BOTTOMRIGHT (−3,−2), the authored sub-rect) — panel x 204.
// `UI-CharacterCreate-ScrollBar-Top` (32×128 at frame TOPRIGHT (-3,4)) and the ClassTrainer
// strip's authored sub-rect (30×123 at BOTTOMRIGHT (-3,-2)), both at panel x 204.
if let Some((top, _)) = &sc.track_top {
panel.spawn((
ScrollHides { scroll },
@@ -228,8 +219,8 @@ fn scrollbar(
step: track_h * s / 2.0,
},
Button,
// At the end of its travel this arrow is disabled — `scroll_visuals` writes it
// and `crate::glue::glue_hilights` reads it, so the sheen dies with the arrow.
// Disabled at the end of travel: `scroll_visuals` writes it and
// `glue_hilights` reads it, so the sheen goes with the arrow.
crate::glue::widgets::GlueDisabled(false),
ImageNode {
image: art.up.clone(),
+19 -23
View File
@@ -1,16 +1,13 @@
//! The create screen's component vocabulary — the markers [`super::screen`] spawns and
//! [`super::refresh`] drives. Pure data; no systems. (The screen-agnostic widget markers —
//! `Hilight`, `ArtSwap`, `GlueBtn`, … — live in [`crate::glue::widgets`], decision 0465.)
//! The create screen's markers: [`super::screen`] spawns them, [`super::refresh`] drives them.
use bevy::prelude::*;
/// The screen root (despawned whole on exit); `s` is the glue scale the tree was baked at —
/// [`super::screen::rescale_screen`] rebuilds it when a window resize changes the scale.
/// The screen root; `s` is the glue scale the tree was built at, rebuilt when it changes.
#[derive(Component)]
pub(super) struct CharCreateUi {
pub(super) s: f32,
}
/// The status line (also written by [`super::create_result`]).
/// The status line.
#[derive(Component)]
pub(super) struct StatusLine;
@@ -25,9 +22,9 @@ pub(super) enum InfoKind {
/// An icon whose texture rect follows the selection.
#[derive(Component)]
pub(super) enum DynIcon {
/// A race button's icon (rect follows the selected sex).
/// A race button's icon; the rect follows the selected sex.
Race(u8),
/// A class slot's icon (the class it maps to follows the selected race).
/// A class slot's icon; its class follows the selected race.
ClassSlot(u8),
/// An info-panel header icon.
Info(InfoKind),
@@ -40,46 +37,45 @@ pub(super) enum DynText {
Name,
InfoTitle(InfoKind),
InfoBody(InfoKind),
/// The race panel's ability lines — the ref's separate gold `CharacterCreateRaceAbilityText`,
/// never part of the white paragraph.
/// The race panel's ability lines, the reference's separate gold
/// `CharacterCreateRaceAbilityText`.
InfoAbilities,
/// A class slot's hover label (name follows the mapped class).
/// A class slot's hover label.
ClassSlotLabel(u8),
}
/// A tint that follows the selected faction: the page backdrop, or a panel's backdrop-bg piece
/// (the ref's `SetBackdropColor` — an `ImageNode` tint with art, a `BackgroundColor` without).
/// A tint that follows the selected faction, the reference's `SetBackdropColor`: an `ImageNode`
/// tint with art, a `BackgroundColor` without.
#[derive(Component)]
pub(super) enum DynTint {
Backdrop,
BoxFill,
}
/// A dial spinner row (row 4 — facial hair — hides when the race's token is `NONE`, the ref rule).
/// A dial row; row 4, facial hair, hides when the race's token is `NONE`, as in the reference.
#[derive(Component)]
pub(super) struct DialRow(pub(super) u8);
/// An info panel's scrollable body (mouse wheel while hovered).
/// An info panel's scrollable body, wheel-scrolled while hovered.
#[derive(Component)]
pub(super) struct InfoScroll;
/// A scrollbar arrow (`GlueScrollUp/DownButtonTemplate`): steps its scroll frame by half a track
/// per click; disabled art at its end stop.
/// A scrollbar arrow (`GlueScrollUp/DownButtonTemplate`), disabled at its end stop.
#[derive(Component)]
pub(super) struct ScrollArrow {
pub(super) scroll: Entity,
/// `true` = the up button (scrolls toward 0).
/// The up button, scrolling toward 0.
pub(super) up: bool,
/// The click step, logical px — the ref's `GetHeight()/2` (half the track).
/// The click step in logical px, the reference's `GetHeight()/2`: half the track.
pub(super) step: f32,
}
/// The scrollbar knob (`UI-ScrollBar-Knob`): rides the scroll fraction along its track; drags.
/// The scrollbar knob (`UI-ScrollBar-Knob`), draggable.
#[derive(Component)]
pub(super) struct ScrollThumb {
pub(super) scroll: Entity,
/// The knob's travel (track minus knob), logical px.
/// Track minus knob, logical px.
pub(super) travel: f32,
}
/// A scrollbar piece that hides while its frame has nothing to scroll — the ref's
/// `scrollBarHideable` (the whole slider) + the Top/Bottom track art's range-changed hide.
/// A scrollbar piece hidden while its frame has nothing to scroll: the reference's
/// `scrollBarHideable` slider and the track art's range-changed hide.
#[derive(Component)]
pub(super) struct ScrollHides {
pub(super) scroll: Entity,
+28 -54
View File
@@ -1,7 +1,5 @@
//! The create screen's live refresh (decision 0423's polish passes) — everything that follows the
//! selection or the pointer after [`super::screen`] spawns the tree: icon rects, per-race dial
//! labels, the GlueStrings info paragraphs, faction tints, the ref's hover/selected visuals
//! (`LockHighlight`, `HighlightFont`, up/down art states), and the info panels' wheel scroll.
//! The create screen's live refresh: everything that follows the selection or the pointer once
//! [`super::screen`] has spawned the tree.
use bevy::input::mouse::{AccumulatedMouseMotion, MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
@@ -21,8 +19,8 @@ use crate::glue::art::{
};
use crate::glue::widgets::{FallbackFace, GlueDisabled, Hilight, HoverLabel, LockHighlight};
/// Refill everything that follows the selection — icon rects, dial labels, info texts, faction
/// tints, class-slot mapping — on selection change or a fresh spawn.
/// Refill icon rects, dial labels, info texts, faction tints and class slots on a selection
/// change or a fresh spawn.
#[allow(clippy::type_complexity)]
pub(super) fn refresh_dynamic(
sel: Res<CreateSelection>,
@@ -96,7 +94,7 @@ pub(super) fn refresh_dynamic(
if let Some((image, rect)) = tc {
node.image = image;
node.rect = Some(rect);
node.color = Color::WHITE; // spawned transparent until real art lands
node.color = Color::WHITE; // spawned transparent until the art lands
}
}
@@ -117,8 +115,7 @@ pub(super) fn refresh_dynamic(
.text(&format!("FACIAL_HAIR_{facial_tok}"), "Facial Hair")
.to_string(),
},
// The name box is five flex items (segments + carets), painted from its
// `EditBoxState` by `refresh_name_box` — never a single string here (decision 0704).
// Painted by `refresh_name_box`.
DynText::Name => continue,
DynText::InfoTitle(InfoKind::Faction) => strings
.text(
@@ -128,11 +125,8 @@ pub(super) fn refresh_dynamic(
.to_string(),
DynText::InfoTitle(InfoKind::Race) => race_name(sel.race).to_string(),
DynText::InfoTitle(InfoKind::Class) => class_name(sel.class).to_string(),
// The info bodies keep the shipped strings VERBATIM: every `FACTION_INFO_*`/
// `RACE_INFO_*`/`CLASS_*` opens with eight literal spaces — that indent IS the ref's
// first-line clearance past the header icon (the FontString is full-width at x=0;
// GlueStrings.lua authors the inset into the text). Trimming it ran line 1 under the
// icon (director's report, 2026-07-20).
// Never trimmed: each `FACTION_INFO_*`, `RACE_INFO_*` and `CLASS_*` string opens with
// eight spaces, which is the reference's first-line clearance past the header icon.
DynText::InfoBody(InfoKind::Faction) => strings
.text(
if alliance {
@@ -147,8 +141,7 @@ pub(super) fn refresh_dynamic(
strings.text(&format!("RACE_INFO_{file}"), "").to_string()
}
DynText::InfoAbilities => {
// The ref's `CharacterCreateEnumerateRaces` join: every `ABILITY_INFO_<FILE><n>`
// line, newline-separated, into the gold ability text.
// `CharacterCreateEnumerateRaces` joins every `ABILITY_INFO_<FILE><n>` by newline.
let mut lines = Vec::new();
let mut n = 1;
while let Some(a) = strings.get(&format!("ABILITY_INFO_{file}{n}")) {
@@ -170,8 +163,7 @@ pub(super) fn refresh_dynamic(
}
}
// The faction tints: the page lean, and the panels' backdrop-bg color (the ref's
// `SetBackdropColor` — the border stays untinted, its color table entry commented out).
// The reference's `SetBackdropColor` tints only the bg; the border's entry is commented out.
for (tint, bg, node) in &mut tints {
let fill = if alliance { ALLIANCE_FILL } else { HORDE_FILL };
match tint {
@@ -186,7 +178,7 @@ pub(super) fn refresh_dynamic(
}
DynTint::BoxFill => {
if let Some(mut node) = node {
node.color = fill; // the texture's own alpha rides along
node.color = fill; // the texture keeps its own alpha
} else if let Some(mut bg) = bg {
bg.0 = fill.with_alpha(FALLBACK_ALPHA);
}
@@ -194,8 +186,7 @@ pub(super) fn refresh_dynamic(
}
}
// The facial dial hides when the race's token is NONE (the ref rule; no 5875 row is, but the
// mechanism is the authored one).
// The facial dial hides when the race's token is `NONE`, as in the reference (no 1.12 row is).
for (row, mut node) in &mut dial_rows {
if row.0 == 4 {
node.display = if facial_tok == "NONE" {
@@ -206,8 +197,7 @@ pub(super) fn refresh_dynamic(
}
}
// Unused class slots collapse (the ref enumerates the valid classes into the first buttons and
// hides the rest — same compaction).
// The reference fills the valid classes into the first buttons and hides the rest.
for (action, mut node) in &mut slots {
if let CreateAction::ClassSlot(i) = action {
node.display = if (*i as usize) < classes.len() {
@@ -219,24 +209,15 @@ pub(super) fn refresh_dynamic(
}
}
/// 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.
/// The create screen's own per-frame visuals: the chosen icon's `LockHighlight`, hover labels,
/// the no-art fallback shade and Create's disabled latch; the shared passes are [`crate::glue`]'s.
/// The lock write keeps its own query, so a button without a `LockHighlight` still gets hover.
#[allow(clippy::type_complexity)]
pub(super) fn refresh_hover(
sel: Res<CreateSelection>,
catalog: Option<Res<CharCreate>>,
art: Res<GlueArt>,
// The glue-panel buttons (Accept/Back/Randomize) are the shared visuals pass's whole —
// art states, caption, fallback shade, hover highlight — hence `Without<GlueBtn>` here.
// Accept, Back and Randomize are wholly the shared visuals pass's.
mut buttons: Query<
(
&CreateAction,
@@ -260,9 +241,8 @@ pub(super) fn refresh_hover(
_ => false,
};
// `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.
// `SetCharacterRace`/`Class`/`Gender`: `LockHighlight()` the chosen one, unlock the rest;
// `glue_hilights` draws the sheen.
for (action, mut locked) in &mut locks {
let is_sel = selected(action);
if locked.0 != is_sel {
@@ -274,14 +254,13 @@ pub(super) fn refresh_hover(
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.)
// Every node has a `BackgroundColor`; only a `FallbackFace`'s is ours to shade.
if fallback {
bg.0 = if lit { BTN_HOVER } else { BTN_BG };
}
for child in children {
if let Ok(mut vis) = labels.get_mut(*child) {
// Without icon art the label IS the button face — always visible.
// Without icon art the label is the button face.
let show = lit || art.races.is_none();
*vis = if show {
Visibility::Inherited
@@ -292,7 +271,6 @@ pub(super) fn refresh_hover(
}
}
// The Create button disarms while a create is in flight (the shared visuals pass renders it).
for (action, mut disabled) in &mut disables {
if matches!(action, CreateAction::Create) {
let want = sel.creating;
@@ -303,7 +281,7 @@ pub(super) fn refresh_hover(
}
}
/// Wheel-scroll a hovered info panel (the ref's GlueScrollFrame `OnMouseWheel`).
/// Wheel-scroll a hovered info panel, the reference's GlueScrollFrame `OnMouseWheel`.
pub(super) fn scroll_info(
mut wheel: MessageReader<MouseWheel>,
mut boxes: Query<(&Interaction, &ComputedNode, &mut ScrollPosition), With<InfoScroll>>,
@@ -326,8 +304,8 @@ fn max_scroll(node: &ComputedNode) -> f32 {
((node.content_size.y - node.size.y) * node.inverse_scale_factor).max(0.0)
}
/// The scrollbar's inputs (`GlueScrollBarTemplate`): an arrow click steps half a track (the ref's
/// `SetValue(GetValue() ± GetHeight()/2)`), the knob drags.
/// The scrollbar's inputs (`GlueScrollBarTemplate`): an arrow click steps half a track,
/// `SetValue(GetValue() ± GetHeight()/2)`, and the knob drags.
pub(super) fn scroll_drive(
motion: Res<AccumulatedMouseMotion>,
arrows: Query<(Entity, &ScrollArrow)>,
@@ -335,8 +313,7 @@ pub(super) fn scroll_drive(
thumbs: Query<(&ScrollThumb, &Interaction)>,
mut scrolls: Query<(&ComputedNode, &mut ScrollPosition), With<InfoScroll>>,
) {
// The arrow steps on the RELEASE — it is a `<Button>` with an `<OnClick>`, not a slider (1533).
// The knob below is the slider half, and a drag is a held press by definition.
// The arrow is a `<Button>` with an `<OnClick>`, so it steps on the release; the knob drags.
for (entity, arrow) in &arrows {
if !clicks.hit(entity) {
continue;
@@ -359,10 +336,8 @@ pub(super) fn scroll_drive(
}
}
/// The scrollbar's look: the knob rides the scroll fraction, the arrows swap up/down/disabled art
/// (disabled at their end stop, the additive highlight on hover), and every [`ScrollHides`] piece
/// vanishes while the panel has nothing to scroll (the ref's `scrollBarHideable` + the Top/Bottom
/// track art's range-changed hide).
/// The scrollbar's look: the knob follows the scroll fraction, the arrows swap up, down and
/// disabled art, and every [`ScrollHides`] piece hides while there is nothing to scroll.
#[allow(clippy::type_complexity)]
pub(super) fn scroll_visuals(
art: Res<GlueArt>,
@@ -399,8 +374,7 @@ pub(super) fn scroll_visuals(
if img.image != *face {
img.image = face.clone();
}
// A scroll arrow at the end of its travel is genuinely disabled — said once, in the
// component the shared sheen pass already reads, rather than re-derived beside it.
// The shared sheen pass reads this.
if off.0 != disabled {
off.0 = disabled;
}
+35 -70
View File
@@ -1,17 +1,6 @@
//! The create screen's **layout** — the reference `CharacterCreate.xml` arrangement rebuilt in
//! Bevy UI (decision 0423's polish passes), full-bleed and scaled to the window: the glue engine
//! renders a 1024×768 virtual screen scaled to the display, so every authored offset/size below is
//! the ref's number times `height / 768`. The widget shapes it places live in [`super::widgets`],
//! the component vocabulary in [`super::parts`], the art in [`super::art`], and the systems that
//! drive it all in [`super::refresh`].
//!
//! Left: the configuration tower — `UI-CharacterCreate-Background` under three stacked
//! `OuterBorder` pieces, the faction banners behind the 2×4 race grid, the gender pair, the
//! valid-classes-only grid, the five `LabelFrame` dial spinners, Randomize. Right: the three
//! `TextPanel-Border` info panels (faction/race/class), bg-tinted per faction like the ref's
//! `SetBackdropColor`, quoting the GlueStrings paragraphs. Center: the transparent-booth model,
//! full-height, drag- or button-rotatable (the big `UI-RotationRight` pair, bottom-left). Bottom:
//! NAME over the `Glue-Tooltip-Border` edit box; Accept over Back in the corner.
//! The create screen's layout, the reference's `CharacterCreate.xml` in Bevy UI. The glue engine
//! scales a 1024×768 virtual screen to the window, so every authored offset and size below is the
//! reference's number times `height / 768`.
use bevy::prelude::*;
use bevy::ui_render::ui_material::MaterialNode;
@@ -68,10 +57,8 @@ pub(super) fn enter_create(
);
}
/// Rebuild the tree when a window resize (mac fullscreen, a drag) has changed the glue scale it
/// was baked at — the create screen has no per-frame materialize (it spawns on entry, after the
/// boot-order traps the other screens dodge), so the rescale watch lives here. Selection and the
/// typed name live in resources and repaint via [`super::refresh`], so the rebuild loses nothing.
/// Rebuild the tree when a resize changes the glue scale; the selection and name live in
/// resources, so the rebuild loses nothing.
pub(super) fn rescale_screen(
mut commands: Commands,
existing: Query<(Entity, &CharCreateUi)>,
@@ -106,14 +93,12 @@ fn spawn_screen(
window: &Query<&Window, With<PrimaryWindow>>,
) {
let font = wow_font(assets);
// The edit box types in `GlueEditBoxFont` — ARIALN, not FRIZQT (GlueFonts.xml).
// The edit box types in `GlueEditBoxFont`, ARIALN, not FRIZQT (`GlueFonts.xml`).
let edit_font: Handle<Font> = assets.load("mpq://Fonts/ARIALN.ttf");
let model_image = match portraits.0.get(GLUE_SLOT) {
Some(PortraitSource::Live(h)) => Some(h.clone()),
_ => None,
};
// The glue engine scales a 1024×768 virtual screen to the window; scale the authored sizes the
// same way so the ref proportions hold at any size.
let s = crate::glue::screen_scale(window.single().ok());
let px = |v: f32| Val::Px(v * s);
let empty = GlueStrings::default();
@@ -132,12 +117,9 @@ fn spawn_screen(
BackgroundColor(BACKDROP),
))
.with_children(|ui| {
// The 3D scene, full-bleed and first (everything else draws over it) — the ref's
// screen IS a fullscreen ModelFFX: the per-race background with the character standing
// in it (the booth renders both into this window-sized target). The whole pane drags
// to rotate, the ref's full-frame mouse rotation; the page tint behind it is the
// no-art fallback. It keeps the WINDOW while the chrome below does not: a pillarbox's
// bars are the booth camera's own output clear inside this same target (1619 §3).
// The 3D scene first, full window: the reference's screen is a fullscreen ModelFFX,
// the race background with the character in it, and the whole frame drags to rotate.
// A pillarbox's bars are the booth camera's own clear inside this target.
let mut pane = ui.spawn((
CreateAction::Model,
Button,
@@ -156,15 +138,13 @@ fn spawn_screen(
})
.id();
// ...and every piece of chrome hangs off the CANVAS — the boxed scene's own rect (decision
// 2091). The race/class towers are this screen's edge-anchored chrome: against the window they
// stand over the bars, and 1587's "no void at 21:9" held only because they did (1619 §2).
// The chrome hangs off the canvas, the boxed scene's rect, never the window.
let mut canvas = commands.spawn((crate::glue::glue_canvas(), ChildOf(root)));
canvas.with_children(|ui| {
left_tower(ui, art, &font, s, strings);
// The WoW logo (`CharacterCreateWoWLogo`, 256×128 at (3,−7)) — after the tower, like the
// ref's frame order (child frames draw over the parent's border art).
// `CharacterCreateWoWLogo` (256×128 at (3,-7)), after the tower, as the reference's
// frame order draws it over the border art.
if let Some(logo) = &art.logo {
ui.spawn((ImageNode::new(logo.clone()), abs(s, 3.0, 7.0, 256.0, 128.0)));
}
@@ -173,8 +153,7 @@ fn spawn_screen(
name_cluster(ui, art, &font, &edit_font, s, strings);
rotate_cluster(ui, art, &font, s);
// Accept over Back, bottom-right (`CharCreateOkayButton` 160×35 over `BackButton` 120×30
// at BOTTOMRIGHT (−50, 20)).
// `CharCreateOkayButton` 160×35 over `BackButton` 120×30 at BOTTOMRIGHT (-50,20).
ui.spawn((Node {
position_type: PositionType::Absolute,
right: px(50.0),
@@ -211,8 +190,7 @@ fn spawn_screen(
});
}
/// The configuration tower (`CharacterCreateConfigurationFrame`, 206×600 at TOPLEFT (28,−74)):
/// frame art, banners, faction headers, the race/gender/class grids, the dial rows, Randomize —
/// The configuration tower, `CharacterCreateConfigurationFrame` (206×600 at TOPLEFT (28,-74)),
/// every child at its authored offset.
fn left_tower(
ui: &mut ChildSpawnerCommands,
@@ -231,9 +209,8 @@ fn left_tower(
..default()
},))
.with_children(|tower| {
// The frame: `UI-CharacterCreate-Background` stretched behind (TOPLEFT of border1 +6 →
// BOTTOMLEFT of border3 +6), three `OuterBorder` pieces stacked over it (224 wide,
// centered on the 206 frame → x −9; heights 236/240/210 with the authored texcoords).
// `UI-CharacterCreate-Background` behind three stacked `OuterBorder` pieces, 224 wide
// centered on the 206 frame (x -9).
if let Some(bg) = &art.tower_bg {
tower.spawn((ImageNode::new(bg.clone()), abs(s, -3.0, 0.0, 218.0, 680.0)));
}
@@ -253,16 +230,14 @@ fn left_tower(
));
}
}
// The banners (`CharacterCreateBanners`, 256×259 at TOP (−2,−60)) behind the race grid.
// `CharacterCreateBanners`, 256×259 at TOP (-2,-60), behind the race grid.
if let Some(banners) = &art.banners {
tower.spawn((
ImageNode::new(banners.clone()),
abs(s, -27.0, 60.0, 256.0, 259.0),
));
}
// Alliance | Horde over the banner tops (bottom-anchored ±50 of the banner center).
// The XML's `text="ALLIANCE"` is a localization key — GlueStrings renders it
// mixed-case ("Alliance"), never the raw key.
// The XML's `text="ALLIANCE"` is a GlueStrings key, shown as "Alliance".
for (key, fallback, center) in
[("ALLIANCE", "Alliance", 51.0), ("HORDE", "Horde", 151.0)]
{
@@ -285,8 +260,7 @@ fn left_tower(
);
}
// The race grid: two columns of 48² check-buttons (col A at (33,68), col B at (127,68),
// row pitch 48+5).
// Two columns of 48² buttons at (33,68) and (127,68), row pitch 48+5.
for (faction, left) in [(ALLIANCE, 33.0), (HORDE, 127.0)] {
tower
.spawn((Node {
@@ -314,7 +288,7 @@ fn left_tower(
});
}
// The gender pair (below race col A: race4's BOTTOMLEFT + (20,−28)).
// The gender pair at race button 4's BOTTOMLEFT + (20,-28).
tower
.spawn((Node {
position_type: PositionType::Absolute,
@@ -348,9 +322,7 @@ fn left_tower(
}
});
// The class grid (3-wide under the banners: cols at x 27/79/131, rows touching) — 8
// slots refreshed to the selected race's valid classes; unused slots collapse (the
// ref's enumerate-then-hide compacts the same way).
// Three wide, columns at x 27/79/131; 8 slots, unused ones collapse.
tower
.spawn((Node {
position_type: PositionType::Absolute,
@@ -378,8 +350,7 @@ fn left_tower(
}
});
// The five dial spinners (`CharacterCustomizationFrameTemplate`, 198×32 rows stacked
// from (4,480) — centered under the class grid).
// `CharacterCustomizationFrameTemplate`, 198×32 rows stacked from (4,480).
tower
.spawn((Node {
position_type: PositionType::Absolute,
@@ -395,10 +366,9 @@ fn left_tower(
}
});
// RANDOMIZE (146×30, centered on the row column). The XML anchors it 25 below the
// dials, but `CharacterCreate_UpdateFacialHairCustomization` re-anchors it on every
// race set — `SetPoint("TOP", Frame5, "BOTTOM", 0, -5)` — so the shipped client always
// shows the 5px gap.
// The XML anchors it 25 below the dials, but
// `CharacterCreate_UpdateFacialHairCustomization` re-anchors it on every race set,
// `SetPoint("TOP", Frame5, "BOTTOM", 0, -5)`, so the 1.12 client shows a 5 px gap.
tower
.spawn((Node {
position_type: PositionType::Absolute,
@@ -422,8 +392,8 @@ fn left_tower(
});
}
/// One dial spinner row: the `CharacterCreate-LabelFrame` 3-slice (64-tall art overhanging the
/// 32-tall row), the centered per-race label, and the 32² arrow pair on the right.
/// One dial row: the `CharacterCreate-LabelFrame` 3-slice (64-tall art over the 32-tall row),
/// the per-race label and the arrow pair.
fn dial_row(
dials: &mut ChildSpawnerCommands,
art: &GlueArt,
@@ -442,8 +412,7 @@ fn dial_row(
},
))
.with_children(|row| {
// LabelFrame: Left 25 at (−5), Middle stretched, Right 25 ending at x 154 (RIGHT −44,
// clearing the arrows) — the 128×64 art's 25|78|25 horizontal slices.
// The 128×64 art's 25|78|25 slices: Left at x -5, Right ending at x 154 (RIGHT -44).
if let Some((frame, size)) = &art.label_frame {
for (left, width, tc) in [
(-5.0, 25.0, [0.0, 0.1953125, 0.0, 1.0]),
@@ -460,7 +429,7 @@ fn dial_row(
));
}
}
// The per-race label, centered on the frame middle (`GlueFontHighlightSmall`).
// `GlueFontHighlightSmall`, centered on the frame middle.
outlined_text(
row,
Node {
@@ -500,8 +469,7 @@ fn dial_row(
});
}
/// NAME over the edit box (`CharacterCreateNameEdit`, 156×40 at BOTTOM (8,50), backdropped in
/// `Glue-Tooltip-Border` — always Alliance-tinted, the ref's `OnLoad`), our status line beneath.
/// NAME over `CharacterCreateNameEdit` (156×40 at BOTTOM (8,50)), with the status line beneath.
fn name_cluster(
ui: &mut ChildSpawnerCommands,
art: &GlueArt,
@@ -536,8 +504,7 @@ fn name_cluster(
font,
s,
);
// The shared glue edit-box chrome (decision 0539) — Alliance-tinted, always (the
// ref's `OnLoad`); the create refresh writes the typed name into the marker.
// Always Alliance-tinted, as the reference's `OnLoad` sets it.
crate::glue::widgets::glue_edit_box(
cluster,
art,
@@ -549,8 +516,7 @@ fn name_cluster(
(15.0, 0.0, 0.0, 0.0), // CharacterCreate.xml: TextInsets left 15 only
s,
);
// Empty until a create fails — the ref surfaces errors in a dialog; this line is our
// minimal stand-in, never an idle hint.
// Empty until a create fails; the reference shows the error in a dialog, not built.
outlined_text(
cluster,
Node::default(),
@@ -568,8 +534,8 @@ fn name_cluster(
});
}
/// The rotate pair (`CharacterCreateRotateLeft/Right`: 50² at BOTTOMLEFT (237,0), overlapping
/// −19) — `UI-RotationRight-Big` art, the left button mirrored, `UI-Common-MouseHilight` on hover.
/// `CharacterCreateRotateLeft/Right`: 50² at BOTTOMLEFT (237,0), overlapping -19, the left one
/// the mirrored `UI-RotationRight-Big`.
fn rotate_cluster(ui: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Font>, s: f32) {
let px = |v: f32| Val::Px(v * s);
ui.spawn((Node {
@@ -647,8 +613,7 @@ pub(super) fn exit_create(
for e in &roots {
commands.entity(e).despawn();
}
// Clear the booth + scene. Back to CharSelect re-establishes both the same frame
// (its `OnEnter` runs after this `OnExit`).
// CharSelect's `OnEnter` runs after this and re-establishes both the same frame.
preview.look = None;
preview.scene = None;
}
File diff suppressed because it is too large Load Diff
+29 -63
View File
@@ -1,15 +1,6 @@
//! The delete confirm dialog (`CharacterDeleteDialog`, decision 0465) — the ref's typed-confirm:
//! the 512×256 `UI-DialogBox` centered over the screen, `CONFIRM_CHAR_DELETE` naming the selected
//! character, the alert icon, an edit box where typing `DELETE_CONFIRM_STRING` ("DELETE") enables
//! Okay, and Okay/Cancel (`GlueDialogButtonTemplate` 200×40). Esc cancels, Enter confirms when
//! armed; Okay sends `CMSG_CHAR_DELETE` through the parked glue channel and plays
//! `gsTitleOptionOK` (Cancel: `gsTitleOptionExit`). The target is snapshotted at open (guid +
//! title), so a roster refresh mid-dialog can never retarget the delete.
//!
//! The edit box is `CharacterDeleteEditBox` verbatim (CharacterSelect.xml): 130×32, the two
//! `UI-ChatInputBorder-Left/Right` 75×32 pieces overhanging it by 10 each side, typing in
//! `GlueFontHighlight` (FRIZQT 15 white — outlined like every glue font), no TextInsets, and the
//! standard drawn caret bar ([`caret_bar`]) blinking on the ref's 0.5 s clock, solid while typing.
//! The delete confirm dialog (`CharacterDeleteDialog`): typing `DELETE_CONFIRM_STRING` ("DELETE")
//! arms Okay, which sends `CMSG_CHAR_DELETE` (`gsTitleOptionOK`); Cancel or Escape close it
//! (`gsTitleOptionExit`). The target is snapshotted at open, so a roster refresh cannot retarget.
use benilla_ui::widget::EditBoxState;
use bevy::input::keyboard::KeyboardInput;
@@ -29,25 +20,21 @@ use crate::sound::GlueSound;
use super::wow_font;
/// The dialog's state: opened by the Delete button (the target snapshotted), driven by
/// [`drive_delete_dialog`], cleared on leave.
/// The delete dialog's state.
#[derive(Resource, Default)]
pub(super) struct DeleteDialog {
pub(super) open: bool,
/// The snapshotted delete target (guid) + its display line pieces (name, level, class name).
/// The snapshotted target: guid, name, level, class name.
target: Option<(u64, String, u8, &'static str)>,
/// What's been typed into the confirm box — a real [`EditBoxState`] (decision 0704), so it
/// has the caret, selection, Ctrl+A and clipboard every other field has. The ref's
/// `letters="32"` cap is the box's own `max_letters`. (`pub(super)` for the shot instrument.)
/// The confirm box's text; the reference's `letters="32"` cap is its `max_letters`.
pub(super) typed: EditBoxState,
/// The spawned dialog root, while up.
root: Option<Entity>,
/// The glue scale the spawned tree was built at — a resize rebuilds it.
/// The glue scale the tree was built at; a resize rebuilds it.
spawned_s: f32,
}
impl DeleteDialog {
/// Open for the character (snapshot — the roster may refresh underneath).
pub(super) fn open_for(&mut self, guid: u64, name: String, level: u8, class: &'static str) {
self.open = true;
self.target = Some((guid, name, level, class));
@@ -60,7 +47,8 @@ impl DeleteDialog {
self.typed.set_text("");
}
/// The typed text matches `DELETE_CONFIRM_STRING` (case-insensitive, the ref's `strupper`).
/// The typed text matches `DELETE_CONFIRM_STRING`, case-insensitive (the reference's
/// `strupper`).
fn armed(&self, confirm: &str) -> bool {
self.typed.text.eq_ignore_ascii_case(confirm)
}
@@ -76,18 +64,15 @@ pub(super) enum DialogAction {
/// The typed-text line inside the edit box.
#[derive(Component)]
pub(super) struct TypedText;
/// The edit box's caret bar (the shared [`caret_bar`]; [`drive_delete_dialog`] blinks it — the
/// dialog's one field always holds the focus, like the create screen's name box).
/// The edit box's caret bar; the dialog's one field always holds the focus.
#[derive(Component)]
pub(super) struct DeleteCaret;
/// The dialog root (despawned on close).
#[derive(Component)]
struct DialogUi;
/// Spawn/despawn the dialog with [`DeleteDialog::open`], feed its typing, and run its flows:
/// Okay (enabled only while the typed text matches) sends the delete; Cancel/Esc close; Enter
/// confirms when armed. Runs before the list refresh so a successful delete's roster update
/// repaints the same frame it lands.
/// Spawn or despawn the dialog, feed its typing and run Okay, Cancel, Enter and Escape. Runs
/// before the list refresh, so a delete's roster update repaints the frame it lands.
#[allow(clippy::type_complexity)]
pub(super) fn drive_delete_dialog(
mut commands: Commands,
@@ -102,16 +87,13 @@ pub(super) fn drive_delete_dialog(
buttons: Query<(Entity, &DialogAction)>,
clicks: Res<crate::glue::GlueClicks>,
mut okay: Query<(&DialogAction, &mut GlueDisabled)>,
// The five row items of the confirm box (segments + carets), painted by the shared
// `paint_glue_field`; plus the host pasteboard and the window handle its Wayland backend needs.
mut parts: Query<(
&crate::glue::widgets::GlueFieldPart,
Option<&mut Text>,
&mut Visibility,
)>,
mut clipboard: NonSendMut<HostClipboard>,
// One query: the window drives the glue scale, and its raw handle carries the `wl_display` the
// Wayland clipboard backend is built from (decision 0702).
// The window drives the glue scale; its raw handle carries the Wayland clipboard's display.
window: Query<
(&Window, Option<&bevy::window::RawHandleWrapper>),
With<bevy::window::PrimaryWindow>,
@@ -122,7 +104,6 @@ pub(super) fn drive_delete_dialog(
let strings = strings.as_deref().unwrap_or(&empty);
let confirm = strings.text("DELETE_CONFIRM_STRING", "DELETE");
// Closed: make sure nothing is spawned, drain stray keys, done.
if !dialog.open {
if let Some(root) = dialog.root.take() {
commands.entity(root).despawn();
@@ -131,8 +112,7 @@ pub(super) fn drive_delete_dialog(
return;
}
// Spawn on the open edge — and respawn when a window resize has changed the glue scale the
// tree was baked at (the typed text lives in the resource, so it survives the rebuild).
// Respawn on a glue-scale change; the typed text lives in the resource and survives.
let s = crate::glue::screen_scale(window.single().ok().map(|(w, _)| w));
if dialog.root.is_some() && dialog.spawned_s != s {
if let Some(root) = dialog.root.take() {
@@ -151,8 +131,7 @@ pub(super) fn drive_delete_dialog(
dialog.spawned_s = s;
}
// Typing: the shared law (decision 0704) — editing, caret, selection and the clipboard trio.
// ENTER/ESCAPE come back unclaimed and are handled by the button/key block above.
// Enter and Escape pass through unclaimed to the key handling below.
let mods = textinput::mods_now(&keys);
let wl = textinput::wayland_display(window.iter().next().and_then(|(_, h)| h));
for ev in keyboard.read() {
@@ -165,7 +144,6 @@ pub(super) fn drive_delete_dialog(
textinput::CharFilter::Any,
);
}
// The dialog's box is always the focused one while it is up.
textinput::tick_caret(&mut dialog.typed, true, time.delta_secs());
paint_glue_field(&dialog.typed, true, parts.iter_mut());
@@ -176,10 +154,9 @@ pub(super) fn drive_delete_dialog(
}
}
// The flows.
let mut do_delete = false;
let mut do_cancel = false;
// Both buttons fire on the RELEASE (1533) — a confirm you can slide off and cancel.
// Both buttons fire on release, so a press can slide off.
for (entity, action) in &buttons {
if !clicks.hit(entity) {
continue;
@@ -209,12 +186,9 @@ pub(super) fn drive_delete_dialog(
}
}
/// Build the dialog tree (`CharacterDeleteDialog`'s shipped layout): the 512×256 DialogBox
/// backdrop centered, the two-tone `CONFIRM_CHAR_DELETE` line (gold lead, white identity — the
/// ref's inline `|cffffffff…|r` span, split across two stacked lines), the instructions, the
/// alert icon, the ChatInputBorder edit box, Okay + Cancel. The vertical chain is the ref's
/// anchor chain resolved: Text1 TOP (0,−16) → instructions 20 below → box 5 below (top 102 —
/// which also centers it on the LEFT (12,+10) alert icon, the authored cross-check).
/// Build `CharacterDeleteDialog`'s layout: the 512×256 DialogBox centered, the question, the
/// instructions, the alert icon, the edit box, Okay and Cancel. The vertical positions resolve the
/// reference's anchor chain: Text1 TOP (0,-16), instructions 20 below, the box 5 below (top 102).
fn spawn_dialog(
commands: &mut Commands,
art: &GlueArt,
@@ -230,9 +204,7 @@ fn spawn_dialog(
.as_ref()
.map(|(_, n, l, c)| (n.clone(), *l, *c))
.unwrap_or_default();
// `CONFIRM_CHAR_DELETE` = "Do you want to delete\n|cffffffff%s Level %d %s|r?" — the
// colour-span split: everything before the |c is the gold lead; the span (+ the trailing "?")
// is the white identity line.
// `CONFIRM_CHAR_DELETE`'s `|cffffffff` span splits it: gold lead above, white identity below.
let raw = strings.text(
"CONFIRM_CHAR_DELETE",
"Do you want to delete\n|cffffffff%s Level %d %s|r?",
@@ -265,7 +237,7 @@ fn spawn_dialog(
..default()
});
boxed.with_children(|b| {
// Backdrop: bg tiled at 32 inside (11,12,12,11), the 32-edge border over it.
// Background tiled at 32 inside insets (11,12,12,11), the 32-edge border over it.
if let (Some(bg), Some(border)) = (&art.dialog_bg, &art.dialog_border) {
b.spawn((
tiled_bg_node(bg.clone(), 32.0, s, Color::WHITE),
@@ -285,7 +257,7 @@ fn spawn_dialog(
overlay(),
));
}
// The alert icon (64² at LEFT (12,10) — center 118 from the top at height 256).
// The 64² alert icon at LEFT (12,10): its center is 118 from the top.
if let Some(alert) = &art.dialog_alert {
b.spawn((
ImageNode::new(alert.clone()),
@@ -299,7 +271,7 @@ fn spawn_dialog(
},
));
}
// The two-tone question (GlueFontNormalLarge 18): gold lead, white identity.
// GlueFontNormalLarge, 18.
for (top, text, color) in [
(16.0, lead.as_str(), GOLD),
(40.0, identity.as_str(), Color::WHITE),
@@ -325,7 +297,6 @@ fn spawn_dialog(
s,
);
}
// The instructions (`CONFIRM_CHAR_DELETE_INSTRUCTIONS`, small).
outlined_text(
b,
Node {
@@ -349,10 +320,8 @@ fn spawn_dialog(
&font,
s,
);
// The edit box (`CharacterDeleteEditBox`, 130×32 centered): the two
// `UI-ChatInputBorder-Left/Right` 75×32 pieces overhanging by 10 each side
// (their authored sub-rects), the typed line in `GlueFontHighlight` (FRIZQT 15
// white, outlined) at the box's left edge (no TextInsets), the caret bar after it.
// `CharacterDeleteEditBox`, 130×32: the `UI-ChatInputBorder-Left/Right` 75×32
// pieces overhang by 10 each side; the text has no TextInsets.
b.spawn((Node {
position_type: PositionType::Absolute,
top: px(102.0),
@@ -416,11 +385,8 @@ fn spawn_dialog(
..default()
},))
.with_children(|f| {
// The same five-item row every other field uses
// (`GlueFieldPart`): segments either side of the selection,
// with a caret slot at each selection edge. Each segment is
// its own `outlined_text`, and the outline copies follow
// automatically (`glue::sync_outline_text`).
// The five-part field row: segments either side of the
// selection, a caret slot at each edge.
let segment = |f: &mut ChildSpawnerCommands, part| {
outlined_text(
f,
@@ -450,8 +416,8 @@ fn spawn_dialog(
});
});
});
// Okay (right edge at center −6) + Cancel (left edge at center +7), 200×40,
// bottom 16 — the GlueDialogButtonTemplate pair.
// The `GlueDialogButtonTemplate` pair, 200×40 at bottom 16: Okay's right edge at
// center -6, Cancel's left edge at center +7.
b.spawn((Node {
position_type: PositionType::Absolute,
bottom: px(16.0),
+26 -44
View File
@@ -1,8 +1,7 @@
//! The select screen's input — clicks (single selects, double enters — the ref's
//! `CharacterSelectButton_OnClick`/`OnDoubleClick`), the bottom buttons, the keyboard (Enter =
//! enter world, Escape = exit, arrows cycle with wrap), and the model rotation (drag anywhere at
//! the pinned `CHARACTER_ROTATION_CONSTANT` 0.6°/px; hold the rotate pair at ±2°/frame). Sounds
//! are the ref's exact set — a bare selection click is silent.
//! The select screen's input: row clicks (single selects, double enters, as the reference's
//! `CharacterSelectButton_OnClick`/`OnDoubleClick`), the bottom buttons, the keyboard, and model
//! rotation (drag at `CHARACTER_ROTATION_CONSTANT` 0.6°/px, or hold a rotate button at ±2°/frame).
//! A bare selection click is silent, as in the reference.
use bevy::input::mouse::AccumulatedMouseMotion;
use bevy::prelude::*;
@@ -17,13 +16,11 @@ use super::{class_name, send_pick, ClientState, Roster};
use crate::glue::{drag_yaw, ROTATE_RATE};
/// The double-click window (the ref rides the OS notion; this is the conventional interval).
/// The double-click window: the conventional interval, where the reference takes the OS's.
const DOUBLE_CLICK_SECS: f32 = 0.4;
/// Button presses + the keyboard: selection, enter world, delete (opens the dialog), create,
/// back/escape (return to the login screen — decision 0539, retiring 0465 §6's exit-the-client
/// collapse), arrow-key cycling. Inert while the delete dialog is up (it owns the keyboard and
/// sits over the buttons).
/// Button presses and the keyboard: Enter enters the world, Escape and Back return to the login
/// screen, the arrows cycle the selection with wrap.
#[allow(clippy::type_complexity)]
pub(super) fn select_input(
buttons: Query<(Entity, &SelectAction)>,
@@ -42,44 +39,36 @@ pub(super) fn select_input(
time: Res<Time>,
mut last_click: Local<Option<(usize, f32)>>,
) {
// A modal owns the input while it is up — the delete confirm, the AddOns list, the realm
// list (which stands over this screen rather than replacing it), or the shared glue dialog
// (a refused character login is said in that one, and its Okay must not double as this
// screen's Enter World / Escape).
// A modal owns the input while it is up: the delete confirm, the AddOns list, the realm list
// over this screen, or the glue dialog (whose Okay must not double as Enter World or Escape).
if dialog.open || panel.open || realms.shown || glue_dialog.is_open() {
return;
}
let now = time.elapsed_secs();
let mut enter_world = false;
let mut back_to_login = false;
// Buttons fire on the RELEASE, over the button that took the press — the reference's stock
// `<Button>` click mask is `LeftButtonUp` alone (1533, `crate::glue::glue_clicks`).
// Buttons fire on release over the pressed button: the stock `<Button>` click mask is
// `LeftButtonUp` alone (`crate::glue::glue_clicks`).
for (entity, action) in &buttons {
if !clicks.hit(entity) {
continue;
}
match *action {
SelectAction::Row(i) if i < roster.chars.len() => {
// Single click selects (silently, like the ref); a second click on the same row
// within the window is the double-click → enter world.
let double =
last_click.is_some_and(|(row, at)| row == i && now - at < DOUBLE_CLICK_SECS);
*last_click = Some((i, now));
// Selecting is gated on the row actually CHANGING — the ref's own
// `CharacterSelectButton_OnClick` is that gate and nothing else, so the row you
// are already on is not re-selected and keeps the facing you dragged into it
// (`Roster::click_row`, decision 2194).
// `CharacterSelectButton_OnClick` selects only when the row changes, so the
// current row keeps the facing dragged into it.
roster.click_row(i);
// `OnDoubleClick` runs the same gated select and then enters the world
// unconditionally — it does not re-test what was selected before.
// `OnDoubleClick` runs the same select, then enters the world unconditionally.
if double {
enter_world = true;
}
}
SelectAction::EnterWorld => enter_world = true,
SelectAction::Delete => {
// The ref plays the click and opens the typed-confirm dialog for the selection
// (a no-selection click just plays the sound — the ref's `selectedIndex > 0` gate).
// With no selection the click only plays the sound (`selectedIndex > 0` gate).
sounds.write(GlueSound("gsCharacterSelectionDelCharacter"));
if let Some(c) = roster.selected_char() {
dialog.open_for(c.guid, c.name.clone(), c.level, class_name(c.class));
@@ -90,13 +79,12 @@ pub(super) fn select_input(
next.set(ClientState::CharCreate);
}
SelectAction::Addons => {
// The reference plays `gsCharacterSelectionOpen`-family click here; ours reuses
// the create-screen open sound rather than inventing a name the client lacks.
// The stock `OnClick` plays no sound (`CharacterSelect.xml:231-233`); this plays
// the create-screen one.
sounds.write(GlueSound("gsCharacterSelectionCreateNew"));
// The whole roster rides along so the panel's "Configure Addons For:" dropdown
// can fan out over every character (decision 1293). The realm resolves exactly
// as `ui_macro::identity`'s does — same fallback, so the enable files the panel
// writes stay keyed the way the world-entry walk reads them (0997, 1191 §7).
// The whole roster feeds the "Configure Addons For:" dropdown. The realm must
// resolve as `ui_macro::identity` does, or the enable files are keyed apart from
// what world entry reads.
let realm = roster
.realm
.as_ref()
@@ -106,11 +94,9 @@ pub(super) fn select_input(
panel.open_for(realm, chars);
}
SelectAction::Back => back_to_login = true,
// The reference's `CHANGE_REALM`: raise the realm list **over** this screen. Nothing
// is torn down and nothing is disconnected — the character park serves the list in
// place and only leaves when a realm is picked, so Cancel puts the player back here
// with the session they never left. The pending pick still has to go, or the fresh
// roster on the OTHER realm would be auto-answered with a guid from this one.
// `CHANGE_REALM`: the realm list rises over this screen with the session kept, so
// Cancel returns here. The pending pick is cleared, or the other realm's roster would
// be answered with a guid from this one.
SelectAction::ChangeRealm => {
sounds.write(GlueSound("gsLoginChangeRealmOK"));
roster.pending_pick = None;
@@ -120,7 +106,6 @@ pub(super) fn select_input(
}
}
// Keyboard: Enter = enter world; Escape = back to login; Up/Left + Down/Right cycle with wrap.
if keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter) {
enter_world = true;
}
@@ -128,9 +113,7 @@ pub(super) fn select_input(
back_to_login = true;
}
if back_to_login {
// The ref's Back leaves select for the login screen (decision 0539): drop the parked
// session (the IO thread re-parks pre-logon) and forget both intents — a deliberate Back
// must not auto-relogin.
// Back drops the parked session and both intents, so it does not auto-relogin.
sounds.write(GlueSound("gsCharacterSelectionExit"));
roster.pending_pick = None;
intent.clear();
@@ -162,9 +145,8 @@ pub(super) fn select_input(
}
}
/// Rotate the selected character: drag anywhere on the scene pane (the ref's full-frame mouse
/// rotation at the pinned 0.6°/px — dragging right increases the facing), or hold a rotate button
/// (±2°/frame; left decrements).
/// Rotate the selected character: drag on the scene (0.6°/px, right increases the facing) or
/// hold a rotate button (±2°/frame, left decrements).
pub(super) fn rotate_model(
panes: Query<(&Interaction, &SelectAction)>,
motion: Res<AccumulatedMouseMotion>,
File diff suppressed because it is too large Load Diff
+24 -65
View File
@@ -1,8 +1,5 @@
//! The select screen's live refresh — everything that follows the roster or the pointer after
//! [`super::screen`] spawns the tree: the row texts + visibility, the row highlight (hover, and
//! LOCKED on the selected row — the ref's `LockHighlight`), the realm banner, the selected name
//! over the model, the buttons' enabled states, and the glue-booth feed (scene by the selected
//! character's race, the geared look).
//! The select screen's live refresh after [`super::screen`] spawns the tree: row texts, the
//! locked row highlight, the realm banner, the selected name, button states and the glue booth.
use bevy::prelude::*;
@@ -15,9 +12,8 @@ use crate::portrait::{GlueLook, GluePreview, SelectLook};
use super::screen::{RealmBanner, RowText, SelectAction, SelectedName, MAX_ROWS};
use super::{class_name, Roster};
/// Refill the row texts + visibility, the selected name, and the realm banner whenever the roster
/// changes (a fresh enum, a selection move) — or when the screen was just (re)spawned (returning
/// from the create screen finds an unchanged roster; the fresh, empty tree must still fill).
/// Refill the rows, the selected name and the realm banner when the roster changes or the screen
/// was just spawned (a return from the create screen finds the roster unchanged).
#[allow(clippy::type_complexity)]
pub(super) fn refresh_list(
roster: Res<Roster>,
@@ -37,7 +33,6 @@ pub(super) fn refresh_list(
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
// Row visibility: rows with a character show; the rest hide (the ref's enumerate-then-hide).
for (action, mut vis) in &mut rows {
if let SelectAction::Row(i) = action {
*vis = if *i < roster.chars.len() {
@@ -47,8 +42,8 @@ pub(super) fn refresh_list(
};
}
}
// Row texts: Name; Info = `CHARACTER_SELECT_INFO` "Level %d %s" (class only — the ghost
// variant appends "(Ghost)"); Location = the zone name off AreaTable.
// Info is `CHARACTER_SELECT_INFO` "Level %d %s" (class only; the ghost variant appends
// "(Ghost)"); Location is the AreaTable zone name.
for (text, mut t) in &mut texts {
let (i, kind) = match text {
RowText::Name(i) => (*i, 0),
@@ -81,8 +76,6 @@ pub(super) fn refresh_list(
t.0 = new;
}
}
// The selected character's name over the model (empty list → empty, the ref's
// UPDATE_SELECTED_CHARACTER arg 0).
if let Ok(mut t) = name.single_mut() {
let new = roster
.selected_char()
@@ -95,9 +88,8 @@ pub(super) fn refresh_list(
if let Ok(mut t) = banner.single_mut() {
let new = match &roster.realm {
Some(realm) => {
// `GetServerName`'s own `isPVP, isRP` pair, from the one table that owns it.
// The banner's normal-realm arm is EMPTY, unlike the realm list's `Normal` — the
// reference leaves `serverType = ""` when neither flag is set.
// `GetServerName`'s `isPVP, isRP` pair. A normal realm has no suffix, unlike
// the realm list's `Normal`: the reference leaves `serverType = ""`.
let suffix = match crate::realm_select::pvp_rp(realm.realm_type) {
(true, true) => strings.text("RPPVP_PARENTHESES", "(RPPVP)"),
(false, true) => strings.text("RP_PARENTHESES", "(RP)"),
@@ -108,13 +100,8 @@ pub(super) fn refresh_list(
.then(|| strings.text("SERVER_DOWN", "Server down"));
realm_banner(&realm.name, suffix, down)
}
// **`CharSelectRealmName:Hide()`** (`CharacterSelect.lua` l.66): with no server name
// the reference hides the FontString outright — it has no string for this case, and
// ours used to invent one (`"Connecting…"`). Empty text is our Hide.
//
// It is also all but unreachable now. The realm is chosen *before* the world dial, so
// by the time this screen exists the session has one; what is left here is the
// no-network harness, which is exactly the case the reference draws nothing for.
// With no server name the reference hides the banner (`CharSelectRealmName:Hide()`,
// `CharacterSelect.lua:67`); empty text is that Hide.
None => String::new(),
};
if t.0 != new {
@@ -123,34 +110,19 @@ pub(super) fn refresh_list(
}
}
/// The realm banner, composed the way `CharacterSelect_OnShow` composes it (`CharacterSelect.lua`
/// l.48-62): the down note is appended to the **name**, and the realm-type suffix goes after that
/// whole thing.
///
/// ```text
/// serverName = serverName.."\n("..TEXT(SERVER_DOWN)..")"; -- only while disconnected
/// CharSelectRealmName:SetText(serverName.." "..serverType);
/// ```
///
/// A separate function because the order is exactly what drifted: ours put the suffix first and the
/// down note last, under a comment claiming the reference's shape. Nothing showed while the suffix
/// was `"(PVP)"` — parentheses read plausibly at either end. Decision 2052 made the suffix the enGB
/// patch's `"PVP"`, a bare word, and the order became legible. A comment could not fail; this can.
/// The realm banner as `CharacterSelect_OnShow` composes it (`CharacterSelect.lua:48-64`): the
/// down note is appended to the name, and the realm-type suffix follows the whole.
fn realm_banner(name: &str, suffix: &str, down: Option<&str>) -> String {
let name = match down {
Some(reason) => format!("{name}\n({reason})"),
None => name.to_string(),
};
// The reference appends `" "..serverType` unconditionally, leaving a trailing space on a realm
// with no type; we trim it, since ours is a laid-out node rather than a Lua string.
// The reference appends `" "..serverType` unconditionally; the trailing space is trimmed.
format!("{name} {suffix}").trim_end().to_string()
}
/// Per-frame interaction visuals + button states: the row highlight (hover ∪ selected — the ref's
/// `LockHighlight` on the selected row), and the enabled states — Enter World / Delete disable on
/// an empty list (the ref's `UpdateCharacterList`), Create hides at the 10-cap or disconnected.
/// (Change Realm was drawn deliberately dead under 0465 §6, until 2056 gave it something to do and
/// 2072 made it work.)
/// Per-frame button states: `LockHighlight` on the selected row, Enter World and Delete disabled
/// on an empty list (`UpdateCharacterList`), Create hidden at the 10-cap or disconnected.
#[allow(clippy::type_complexity)]
pub(super) fn refresh_banner_and_buttons(
roster: Res<Roster>,
@@ -161,8 +133,7 @@ pub(super) fn refresh_banner_and_buttons(
(With<crate::glue::widgets::GlueBtn>, Without<Hilight>),
>,
) {
// `LockHighlight` on the chosen row and nothing about *visibility*: hovering is
// `crate::glue::glue_hilights`' question, and the two used to be one expression here.
// Only the lock: hover is `crate::glue::glue_hilights`'.
for (action, mut locked) in &mut rows {
let SelectAction::Row(i) = action else {
continue;
@@ -182,8 +153,7 @@ pub(super) fn refresh_banner_and_buttons(
disabled.0 = want;
}
}
// Create New Character shows while the realm answered and a slot is free (the ref hides it
// disconnected or at MAX_CHARACTERS_PER_REALM = 10).
// The reference hides Create disconnected or at `MAX_CHARACTERS_PER_REALM` (10).
let show_create = roster.realm.is_some() && roster.chars.len() < MAX_ROWS;
for (action, mut vis) in &mut create_vis {
if matches!(action, SelectAction::CreateChar) {
@@ -196,32 +166,23 @@ pub(super) fn refresh_banner_and_buttons(
}
}
/// Feed the glue booth from the roster (decision 0465): the scene is the SELECTED character's
/// race's (the ref's `SetBackgroundModel` on the enum fileString — Orc before a list arrives or
/// with an empty account, the ref's OnLoad default), the look its geared enum record. Runs every
/// frame (cheap compares inside `GluePreview` writers — the builder keys on value change).
/// Feed the glue booth: the scene is the selected character's race (`SetBackgroundModel`; Orc
/// with no character, the reference's OnLoad default), the look its geared enum record.
pub(super) fn feed_glue_preview(
roster: Res<Roster>,
mut preview: ResMut<GluePreview>,
mut showing: Local<Option<u64>>,
) {
// **The facing belongs to the displayed character, not to the screen** (1533): turning one
// character must not hand its angle to the next one you click, and a fresh character faces the
// stage's own forward until you turn it. `GluePreview::yaw` is one resource shared by all three
// glue screens, so the reset lives at the selection edge rather than in the resource.
//
// Keyed on the selection **counter**, not on who is shown: the ref's `SelectCharacter` zeroes
// the facing unconditionally (`0x472950`, above the already-built discriminator), so selecting
// the same index again — what a roster refresh does — re-squares the character too. A *click*
// on the selected row is gated out one level up and never gets here (`Roster::click_row`,
// 2194). See `Roster::select_seq`.
// Each select zeroes the facing, even of the same index (`SelectCharacter`, `0x472950`),
// so this keys on the selection counter, not on who is shown. `GluePreview::yaw` is shared
// by all three glue screens, so the reset lives here.
if *showing != Some(roster.select_seq) {
*showing = Some(roster.select_seq);
preview.yaw = 0.0;
}
let (race, look) = match roster.selected_char() {
Some(c) => (c.race, Some(GlueLook::Select(SelectLook::from(c)))),
None => (2, None), // UI_Orc — the ref's initial/empty-account scene
None => (2, None), // UI_Orc, the reference's empty-account scene
};
let scene = Some(crate::portrait::GlueScene::Race(race));
if preview.scene != scene {
@@ -236,7 +197,7 @@ pub(super) fn feed_glue_preview(
mod tests {
use super::*;
/// The composition order, pinned against `CharacterSelect.lua` l.48-62.
/// The composition order of `CharacterSelect.lua:48-64`.
#[test]
fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() {
assert_eq!(realm_banner("Kalimdor", "PVP", None), "Kalimdor PVP");
@@ -245,8 +206,6 @@ mod tests {
"Kalimdor\n(Server down) PVP",
"the suffix follows the whole name+note, not the name alone"
);
// No realm type: the reference's unconditional " " is all that would be left, and a banner
// does not end in whitespace.
assert_eq!(realm_banner("Kalimdor", "", None), "Kalimdor");
assert_eq!(
realm_banner("Kalimdor", "", Some("Server down")),
+45 -110
View File
@@ -1,17 +1,5 @@
//! The select screen's **layout** — the reference `CharacterSelect.xml` arrangement rebuilt in
//! Bevy UI (decision 0465), full-bleed and scaled to the window: the glue engine renders a
//! 1024×768 virtual screen scaled to the display, so every authored offset/size below is the
//! ref's number times `height / 768`.
//!
//! Bottom layer: the glue booth's fullscreen render (the `UI_<Race>` scene + the geared selected
//! character), drag-anywhere to rotate. Over it: the WoW logo (TOPLEFT), the selected character's
//! name (`GlueFontNormalHuge` at BOTTOM (0,100)), Enter World (200×60 at BOTTOM (0,30)) with the
//! rotate pair tucked under it, Back (BOTTOMRIGHT (−30,25)) and Delete Character to its left, and
//! the right-column character frame: 260×642 at TOPRIGHT (−5,−15), `Glue-Tooltip` backdrop tinted
//! `DEFAULT_TOOLTIP_COLOR` at 0.85 alpha, holding the realm banner, the Change Realm
//! button (back to [`crate::realm_select`]'s list), ten 256×70 row buttons from TOPLEFT
//! (24,−65) at the authored 57 px pitch (13 px overlap, hit-inset 15), and Create New Character at
//! the frame's BOTTOM (0,15). The delete dialog is [`super::dialog`]'s.
//! The select screen's layout, `CharacterSelect.xml` rebuilt in Bevy UI. The glue screen is a
//! 1024×768 virtual screen, so every authored offset and size is scaled by `height / 768`.
use bevy::prelude::*;
use bevy::ui_render::ui_material::MaterialNode;
@@ -28,30 +16,28 @@ use benilla_assets::WorldAssets;
use super::wow_font;
/// One clickable control on the screen — a single component so one query dispatches every button.
/// One clickable control on the screen.
#[derive(Component, Clone, Copy, PartialEq, Eq)]
pub(super) enum SelectAction {
/// The fullscreen scene pane (drag to rotate — no click action).
/// The fullscreen scene pane, dragged to rotate.
Scene,
/// A character-list row (0-based roster index; single click selects, double click enters).
/// A character-list row by 0-based roster index.
Row(usize),
EnterWorld,
/// Back to the login screen (the ref's flow — decision 0539 retired 0465 §6's
/// exit-the-client collapse).
/// Back to the login screen.
Back,
Delete,
CreateChar,
/// Back to the realm list — drops the parked session, keeps the logon.
/// Raise the realm list over this screen, keeping the session.
ChangeRealm,
/// Open the AddOns list (decision 1197) — the reference's `CharacterSelectAddonsButton`.
/// Open the AddOns list (`CharacterSelectAddonsButton`).
Addons,
RotateLeft,
RotateRight,
}
/// Root of the select screen (despawned whole on exit). `with_art` records whether the tree was
/// built with the client art present — an artless early spawn upgrades once the art lands; `s`
/// records the glue scale it was built at, so a window resize (mac fullscreen) rebuilds it too.
/// Root of the select screen. `with_art` and the glue scale `s` it was built at let an artless
/// spawn or a resize rebuild it.
#[derive(Component)]
pub(super) struct CharSelectUi {
pub(super) with_art: bool,
@@ -72,32 +58,26 @@ pub(super) enum RowText {
}
const SCREEN_Z: i32 = 1100;
/// `DEFAULT_TOOLTIP_COLOR` (AccountLogin.lua): border rgb + bg rgb; the frame applies bg at 0.85.
/// `DEFAULT_TOOLTIP_COLOR` (`AccountLogin.lua`): border and background rgb, background at 0.85.
const FRAME_BORDER: Color = Color::srgb(0.8, 0.8, 0.8);
const FRAME_FILL: Color = Color::srgb(0.09, 0.09, 0.09);
const FRAME_FILL_ALPHA: f32 = 0.85;
/// The list's row geometry (`CharSelectCharacterButtonTemplate` + the button anchors): 256×70
/// buttons whose next TOP anchors 13 above the previous BOTTOM — net pitch 57; the hit rect drops
/// the bottom 15 (the visible/clickable row is 55 tall).
/// Row geometry (`CharSelectCharacterButtonTemplate`): 256×70 buttons, each TOP 13 above the
/// previous BOTTOM (pitch 57), the hit rect dropping the bottom 15 (55 tall).
pub(super) const MAX_ROWS: usize = 10;
const ROW_W: f32 = 256.0;
const ROW_HIT_H: f32 = 55.0;
const ROW_PITCH: f32 = 57.0;
/// Entry is a cheap state reset only — the tree spawns via [`materialize_screen`], because the
/// INITIAL state's `OnEnter` fires during app startup, before the MPQ chain / booth slots exist
/// (a one-shot spawn here came up artless with no scene pane — the boot-order trap the create
/// screen never sees, entered seconds later).
/// Entry only resets state: the initial state's `OnEnter` fires at startup, before the MPQ chain
/// and booth slots exist, so the tree spawns in [`materialize_screen`].
pub(super) fn enter_select(mut preview: ResMut<GluePreview>) {
// The model faces the camera on entry (the C-side facing global's zero default; the create
// screen's −15° is its own reset). The booth feed (`refresh::feed_glue_preview`) establishes
// the scene + look from the roster each frame.
// The model faces the camera on entry: the reference's facing global defaults to zero.
preview.yaw = 0.0;
}
/// Spawn the screen tree once its prerequisites exist — and upgrade an artless early spawn the
/// moment the client art lands (despawn + respawn; the tree is cheap and static). With no client
/// data at all the artless tree still spawns after a short grace (the graceful-absence posture).
/// Spawn the screen tree once the art is loaded, respawn it when art lands after an artless spawn
/// or the glue scale changes; with no client data it spawns artless after a second.
pub(super) fn materialize_screen(
mut commands: Commands,
existing: Query<(Entity, &CharSelectUi)>,
@@ -118,8 +98,6 @@ pub(super) fn materialize_screen(
let s = crate::glue::screen_scale(window.single().ok());
match existing.single() {
Ok((root, ui)) => {
// Rebuild when the art lands after an early artless spawn, or when a window resize
// (mac fullscreen, a drag) has changed the glue scale the tree was baked at.
if (!ui.with_art && with_art) || ui.s != s {
commands.entity(root).despawn();
spawn_screen(
@@ -180,13 +158,8 @@ fn spawn_screen(
BackgroundColor(BACKDROP),
))
.with_children(|ui| {
// The 3D scene, full-bleed and first (everything else draws over it) — the ref's
// screen IS the fullscreen ModelFFX: the selected race's scene with the geared
// character standing in it. The whole pane drags to rotate (the ref's full-frame mouse
// rotation); the page tint behind it is the no-art fallback. It keeps the WINDOW while
// the chrome below does not: a pillarbox's bars are the booth camera's own output
// clear inside this window-sized target (1619 §3), so the pane that samples it covers
// the window and brings the bars with it.
// The fullscreen ModelFFX scene, first so everything draws over it. It covers the
// window, not the canvas: a pillarbox's bars are the booth camera's own clear.
let mut pane = ui.spawn((
SelectAction::Scene,
Button,
@@ -205,18 +178,15 @@ fn spawn_screen(
})
.id();
// ...and every piece of chrome hangs off the CANVAS — the boxed scene's own rect (decision
// 2091). Anchored to the window instead, the logo and the whole right-hand character frame
// (with Delete Character and Back) stood out in the bars at 21:9 (B377).
// The chrome hangs off the canvas, the boxed scene's rect, so none of it sits in the bars.
let mut canvas = commands.spawn((crate::glue::glue_canvas(), ChildOf(root)));
canvas.with_children(|ui| {
// The WoW logo (`CharacterSelectLogo`, 256×128 at TOPLEFT (3,−7)).
// `CharacterSelectLogo`, 256×128 at TOPLEFT (3,-7).
if let Some(logo) = &art.logo {
ui.spawn((ImageNode::new(logo.clone()), abs(s, 3.0, 7.0, 256.0, 128.0)));
}
// The selected character's name (`CharSelectCharacterName`, GlueFontNormalHuge 22 gold,
// BOTTOM (0,100)).
// `CharSelectCharacterName`, GlueFontNormalHuge gold at BOTTOM (0,100).
ui.spawn((Node {
position_type: PositionType::Absolute,
bottom: px(100.0),
@@ -241,7 +211,7 @@ fn spawn_screen(
);
});
// Enter World (`CharSelectEnterWorldButton`, GlueButtonTemplate 200×60 at BOTTOM (0,30)).
// `CharSelectEnterWorldButton`, GlueButtonTemplate 200×60 at BOTTOM (0,30).
ui.spawn((Node {
position_type: PositionType::Absolute,
bottom: px(30.0),
@@ -265,11 +235,7 @@ fn spawn_screen(
rotate_cluster(ui, art, &font, s);
// **AddOns** (decision 1197) — the reference's `CharacterSelectAddonsButton`, which 1191
// §5 recorded as never built because there was no list behind it. There is now. Bottom
// LEFT, out of the way of the Enter World / Back cluster; the reference sits it in the
// same lower band. Shown only when something is installed, exactly as the reference's
// `UpdateAddonButton` hides it on `GetNumAddOns() == 0`.
// `CharacterSelectAddonsButton`, hidden on `GetNumAddOns() == 0` (`UpdateAddonButton`).
if super::addons::AddonsPanel::any_installed() {
ui.spawn((Node {
position_type: PositionType::Absolute,
@@ -293,7 +259,7 @@ fn spawn_screen(
});
}
// Back (100×35 at BOTTOMRIGHT (−30,25)) and Delete Character (165×35 at its LEFT).
// Back (100×35 at BOTTOMRIGHT (-30,25)) and Delete Character (165×35 at its LEFT).
ui.spawn((Node {
position_type: PositionType::Absolute,
right: px(30.0),
@@ -330,9 +296,7 @@ fn spawn_screen(
});
}
/// The right-column character frame (`CharacterSelectCharacterFrame`, 260×642 at TOPRIGHT
/// (−5,−15)): the `Glue-Tooltip` backdrop, the realm banner + disabled Change Realm, the ten row
/// buttons, and Create New Character at the bottom.
/// The right-column `CharacterSelectCharacterFrame`, 260×642 at TOPRIGHT (-5,-15).
fn character_frame(
ui: &mut ChildSpawnerCommands,
art: &GlueArt,
@@ -350,8 +314,8 @@ fn character_frame(
..default()
},))
.with_children(|frame| {
// The backdrop: bg inset (10,5,4,9) tiled at 16, the 16-edge border over it — both
// tinted with DEFAULT_TOOLTIP_COLOR (border rgb / bg rgb at 0.85), the ref's OnLoad.
// Background inset (10,5,4,9) tiled at 16 under the 16-edge border, both tinted with
// `DEFAULT_TOOLTIP_COLOR` in the reference's OnLoad.
if let (Some(bg), Some(border)) = (&art.tooltip_bg, &art.name_border) {
frame.spawn((
tiled_bg_node(
@@ -376,7 +340,7 @@ fn character_frame(
overlay(),
));
}
// The realm banner (`CharSelectRealmName`, GlueFontDisableLarge 18 at TOP (0,−10)).
// `CharSelectRealmName`, GlueFontDisableLarge at TOP (0,-10).
outlined_text(
frame,
Node {
@@ -397,9 +361,6 @@ fn character_frame(
font,
s,
);
// Change Realm (below the banner) — live. It was drawn permanently disabled for as
// long as there was no realm list behind it to go back to; there is now, and it
// costs a world dial rather than a re-login (`crate::realm_select`).
frame
.spawn((Node {
position_type: PositionType::Absolute,
@@ -421,13 +382,12 @@ fn character_frame(
s,
);
});
// The ten row buttons (256×70 from TOPLEFT (24,−65), net pitch 57 — the 13 px overlap
// is the hit-rect's dropped bottom 15; our nodes ARE the hit shape, 55 tall).
// From TOPLEFT (24,-65); the nodes are the 55-tall hit shape.
for row in 0..MAX_ROWS {
row_button(frame, art, font, row, s);
}
// Create New Character (width ≈ text+50 ×45 at the frame BOTTOM (0,15) — the ref's
// shipped client always shows it here; the per-free-row anchor is commented out).
// Create New Character at the frame's BOTTOM (0,15); the reference's per-free-row
// anchor is commented out.
frame
.spawn((Node {
position_type: PositionType::Absolute,
@@ -452,10 +412,8 @@ fn character_frame(
});
}
/// One character-list row (`CharSelectCharacterButtonTemplate`): Name (GlueFontNormal 15 gold at
/// TOPLEFT (0,−5)), Info (GlueFontHighlightSmall 12 white below), Location (GlueFontDisableSmall
/// 12 gray below), and the ADD-mode `Glue-CharacterSelect-Highlight` card (256×74 at (−20,+8)) —
/// lit on hover and LOCKED on the selected row.
/// One `CharSelectCharacterButtonTemplate` row: Name, Info and Location lines and the ADD-mode
/// `Glue-CharacterSelect-Highlight` card (256×74 at (-20,+8)), lit on hover, locked when selected.
fn row_button(
frame: &mut ChildSpawnerCommands,
art: &GlueArt,
@@ -468,8 +426,8 @@ fn row_button(
.spawn((
SelectAction::Row(row),
Button,
LockHighlight::default(), // `LockHighlight` on the selected row (refresh.rs)
Visibility::Hidden, // shown by the refresh while the roster has this row
LockHighlight::default(),
Visibility::Hidden,
Node {
position_type: PositionType::Absolute,
left: px(24.0),
@@ -548,10 +506,8 @@ fn row_button(
});
}
/// The rotate pair (`CharacterSelectRotateLeft/Right`, 50² each): the left button's TOP anchors to
/// Enter World's BOTTOM at (−15,+19) — the pair sits flush with the screen's bottom edge, tucked
/// under the button — the right overlapping −19; `UI-RotationRight-Big` art with the left mirrored,
/// `UI-Common-MouseHilight` ADD on hover.
/// The rotate pair (`CharacterSelectRotateLeft/Right`, 50² each): the left's TOP anchors to Enter
/// World's BOTTOM at (-15,+19), the right overlaps it by 19; the left mirrors the right's art.
fn rotate_cluster(ui: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Font>, s: f32) {
let px = |v: f32| Val::Px(v * s);
ui.spawn((Node {
@@ -625,26 +581,10 @@ fn rotate_cluster(ui: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Fo
});
}
/// **A world loading cover outranks the glue screen.**
///
/// The glue screens sit at [`SCREEN_Z`] = 1100 and the loading screen at 1000, so the glue draws
/// *over* the cover. That is right for the logout direction — the world→glue swap must show the
/// glue — and wrong for the entry one: the cover is raised in `WorldStage::Present` while we are
/// still `CharSelect`, so the raise frame renders the character screen on top of it and the FIRST
/// frame that can show the cover is the **state-flip** frame, which is also the frame the whole
/// world arrives on. Measured this round: click → cover on the glass was ~100 ms, of which the
/// flip frame alone was 60 — the character screen held, frozen, for every millisecond of it. That
/// is the director's *"the char freezes briefly before the loading screen appears"*; 1345 fixed
/// the same shape for the FrameXML burst and left this half standing, because nothing had ever
/// asked which frame the cover first *drew* on.
///
/// Hiding the root moves the cover onto the raise frame's own render, a whole frame earlier and —
/// far more importantly — **off** the flip frame's critical path: the cover is already drawn and
/// presenting while that frame churns. Registered after `WorldStage::Present` so it reads *this*
/// frame's raise; visibility propagates in `PostUpdate` and renders the same frame, exactly like
/// the loading screen's own show/hide. Read from [`LoadingScreen::covering`] and not from
/// [`crate::loading_screen::EntryCover`]: this is the one consumer that must react while still
/// `CharSelect`, which is precisely the frame `EntryCover` cannot count.
/// Hide the screen under a world loading cover. The glue at z 1100 draws over the loading screen
/// at z 1000, and on world entry the cover rises while still `CharSelect`; hiding the root shows it
/// on that frame, not the state-flip frame the world loads on. Runs after `WorldStage::Present` and
/// reads `LoadingScreen::covering`, since `EntryCover` does not count this frame.
pub(super) fn hide_under_world_cover(
loading: Res<crate::loading_screen::LoadingScreen>,
mut roots: Query<&mut Visibility, With<CharSelectUi>>,
@@ -671,15 +611,10 @@ pub(super) fn exit_select(
for e in &roots {
commands.entity(e).despawn();
}
// Clear the booth + scene (entering the world or the create screen; the latter re-establishes
// its own the same frame) and drop any open dialog state.
preview.look = None;
preview.scene = None;
dialog.close();
// The shared glue dialog is this screen's too while it is up (a refused character login), and
// it must not follow us into the world or onto the create screen — the login screen's own
// `exit_login` closes it on the same edge for the same reason. The tree goes with it **here**
// rather than being left to the driver: the driver runs on the glue screens only, so on the
// edges out of the glue layer there would be nobody left to despawn the root.
// The glue dialog must not follow into the world or the create screen. Its tree is despawned
// here, since its driver runs on the glue screens only.
glue_dialog.dismiss(&mut commands);
}
+122 -305
View File
@@ -1,71 +1,24 @@
//! **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`]).
//! Chat bubbles, `CGChatBubbleFrame`: the speech bubble a chat line spawns over the speaker, a
//! 2-D overlay like the V-plate ([`crate::vplates`]).
//!
//! The pinned law, transcribed:
//! - **Spawn** (`0x608ac0`, one caller — the SMSG_MESSAGECHAT display path): the sender GUID
//! typed-looks-up to a live unit or no bubble; CVar select `ChatBubbles` (party lines:
//! `ChatBubblesParty`); non-empty text; **no active V-plate on the unit** (`0x608adc` —
//! mutual exclusion both ways: a live bubble in turn suppresses the floating overhead name,
//! [`BubblesActive`]); the local player resolves; 3-D dist² ≤ 400 (20 yd). Self is NOT
//! excluded. **Replace, never queue**: a new line tears the old bubble down first.
//! - **Lifetime** (`0x4b1810`): word-count-scaled, self-vs-other asymmetric —
//! `2750 + 750·(words−1)` ms for others, `1500 + 500·(words−1)` for the local player
//! (your own bubbles are shorter-lived); words = space/tab-run count. 250 ms linear fade-in;
//! at `create + duration + 250` a permanent 250 ms fade-out, then the frame recycles. The
//! 20 yd gate re-tests **every frame**: out of range fades out *recoverable*, back in range
//! fades back in.
//! - **Geometry** (`0x4b0940`/`0x4b1600`): the classic `Backdrop` construct —
//! `ChatBubble-Background` flat fill, `ChatBubble-Backdrop` 8-piece edge
//! ([`benilla_ui::script::backdrop::pieces`], the same engine geometry FrameXML backdrops
//! use), `ChatBubble-Tail` a separate square of side = the border-unit, TOPRIGHT on the
//! frame's BOTTOM + (0, border/4). Border-unit = insets = edge-size = **16/1024 of the
//! screen width** (`G44·16/(S·1024)`); text = `NAMEPLATE_FONT` at 0.01 gx, wrap hard-capped
//! at **0.2 gx**, floor 2·border-unit; the frame hugs the text layout ± **0.01 gx** on all
//! four sides; text colored by chatType (the 94-entry table ≡ [`default_color`]), rendered
//! PLAIN (`||`→`|`, color/hyperlink escapes stripped — [`sanitize`]).
//! - **Anchor** (`0x4b0c30`): `worldZ = unit.z + attachmentHeight·modelScale + 0.7`, projected
//! by the plate/name projector, seated **BOTTOM at the point, growing upward** (the mirror
//! of the plate, which hangs down). `attachmentHeight` is `0x4b0e38 call 0x711a20` — a read of
//! 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`], 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.)
//! - Spawn (`0x608ac0`, from the `SMSG_MESSAGECHAT` display path): the sender resolves to a live
//! unit, its CVar is on, the text is non-empty, the unit carries no V-plate (`0x608adc`; a live
//! bubble in turn hides the overhead name, [`BubblesActive`]), and the local player is within
//! 20 yd (3-D). Self is not excluded. A new line replaces the old bubble, never queues.
//! - Anchor (`0x4b0c30`): unit z + height × model scale + 0.7, bottom-seated, growing upward. The
//! height is the Stand sequence box's Z extent from the model header (`0x711a20`), not the posed
//! attachment the overhead name reads (`0x608640`), latched once per chat line.
//! - Stacking (`0x4b1060`): every frame the bubbles sort farthest-first by camera distance and
//! take frame levels 2, 3, 4…, so whole cards stack with the nearest speaker's on top.
//!
//! Named divergences (all deliberate):
//! - ~~**`ChatBubblesParty` defaults ON**~~ — **no longer a divergence (1804).** It shipped `"1"`
//! against the binary's `"0"` from 0598, on the director's `/p` ask; both CVars now boot at the
//! registrar's own values and the Chat page's row is the way back on. Same walk-back as
//! [`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, 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,
//! model-scaled") between the overhead-name chain `0x608640` and the bubble's `0x711a20`. They
//! are not equivalent, and they differ on exactly the axis that mattered: `0x608640` reads the
//! live posed palette and tracks the pose, `0x711a20` reads file bytes. The overhead *name*
//! keeps `0x608640`; the bubble now takes the Stand-box constant the bytes actually specify. It
//! sits **0.199 model units lower** on a human male (2.0128 vs the attachment's 2.2120) — a
//! deliberate, measured move toward the reference, not a regression.
//! - **Sizes ride the plates' damped diagonal basis** ([`plate_basis`], decisions 0185/0186)
//! so bubble text and plate text stay the same em at every window — the same director-pinned
//! deviation from the unbounded byte law.
//! - **No per-frame occlusion fade**: the client also fades a bubble whose speaker model isn't
//! render-visible (`0x7103d0`/`0x6704c0`); v1 re-tests distance only. Residual named in 0598.
//! Only say, yell, party and monster say/yell bubble: the reference's gate is "sender resolves",
//! but the wire-type remap (`0x49a870`) that would admit other kinds is untraced.
//!
//! (0598 briefly shipped the plate-blocks-bubble gate UN-transcribed — bubbles stacked over our
//! then-always-on plates. 0599 restored the faithful gate and booted friendly plates OFF
//! instead, so friendly/party bubbles have room the reference way.)
//! Deviation: sizes ride the plates' damped diagonal basis ([`plate_basis`]), not the unbounded
//! reference law, so bubble text and plate text keep one em at every window size.
//!
//! Not built: the reference also fades a bubble whose speaker's model data is not resident or whose
//! scene-entity flag is clear (`0x7103d0`/`0x6704c0`); only distance is re-tested here.
use std::collections::HashMap;
@@ -85,18 +38,12 @@ use crate::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlate
use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::view::WorldCamera;
/// The two CVars (registrar `0x603280`), host side. **Registered knobs since decision 1139** —
/// 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"`).
/// `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.
/// The two bubble CVars (registrar `0x603280`), booting at the reference's defaults.
#[derive(Resource)]
pub(crate) struct BubbleConfig {
/// `ChatBubbles` — say/yell and their monster variants.
/// `ChatBubbles`: say, yell and their monster variants.
pub(crate) all: bool,
/// `ChatBubblesParty` — party lines, which the client gates separately.
/// `ChatBubblesParty`: party lines, which the reference gates separately.
pub(crate) party: bool,
}
@@ -109,50 +56,41 @@ impl Default for BubbleConfig {
}
}
/// The bubble art (`0x4b0940` ctor) — the shared tooltip-family textures.
/// The bubble art (`0x4b0940`).
const BG_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Background";
const EDGE_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Backdrop";
const TAIL_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Tail";
/// The 0.7 yd lift over the attachment height (`[0x7ffd7c]` = 0.699999988).
/// The 0.7 yd lift over the anchor height (`[0x7ffd7c]`).
const LIFT: f32 = 0.7;
/// The spawn + per-frame range gate: dist² ≤ 400 (20 yd, `[0x806798]` — same as the plates).
/// The spawn and per-frame range gate, 20 yd squared (`[0x806798]`, the plates' too).
const MAX_DIST_SQ: f32 = 400.0;
/// The fade ramps: 250 ms linear, in and out (`0x4b0ea0`/`0x4b0ee0`).
/// The 250 ms linear fade, in and out (`0x4b0ea0`/`0x4b0ee0`).
const FADE_SECS: f32 = 0.25;
/// `NAMEPLATE_FONT` at 0.01 gx — the same em law as the plate name.
/// `NAMEPLATE_FONT` at 0.01 gx, the plate name's em.
const TEXT_H: f32 = 0.01;
/// The auto-fit body margin: text layout ± 0.01 gx on all four sides (`0x3c23d70a`).
/// The body margin around the text layout, all four sides (`0x3c23d70a`).
const MARGIN: f32 = 0.01;
/// The text wrap hard cap, gx (`[0x80679c]` = 0.2).
const WRAP_W: f32 = 0.2;
/// Border-unit = edge-size = insets = tail side: 16/1024 of the screen WIDTH
/// (`G44·16.0/(S·1024.0)` — G44/S nets the width in the diagonal gx basis).
/// The border unit (edge size, insets, tail side): 16/1024 of the screen width.
const BORDER_FRAC: f32 = 16.0 / 1024.0;
/// Paint order **inside one bubble's frame level** — the four pieces, back→front. The tail draws
/// 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 — `0x76fb00`).
/// Paint order inside one bubble's level: the tail covers the bottom edge piece and the text draws
/// last, as the reference's batch drains textures before font strings (`0x76fb00`).
const Z_BG: u64 = 0;
const Z_EDGE: u64 = 1;
const Z_TAIL: u64 = 2;
const Z_TEXT: u64 = 3;
/// The z base for the bubble at `rank` in this frame's farthest→nearest order — the port of the
/// reference's per-frame `SetFrameLevel(2 + i)` walk (`0x4b12d5`–`0x4b1312`, decision 1504). One
/// LEVEL per bubble, [`overlay_z::BUBBLE_STRIDE`] keys wide, so a whole bubble stacks over a whole
/// bubble; the whole band stays under the V-plates ([`crate::ui_pass::overlay_z`]).
/// The z base for the bubble at `rank`, farthest first: the reference's per-frame
/// `SetFrameLevel(2 + i)` walk (`0x4b12d5`-`0x4b1312`), one level per bubble, under the V-plates.
fn level_z(rank: usize) -> u64 {
let level = (rank as u64).min(overlay_z::BUBBLE_MAX_LEVEL);
overlay_z::BUBBLE + level * overlay_z::BUBBLE_STRIDE
}
/// Which CVar gates this chat kind's bubble — `None` = the kind never bubbles in v1.
/// PARTY selects `ChatBubblesParty`, every other bubbling kind `ChatBubbles` (`0x608b0d`).
/// Guild/officer/whisper/emote are structurally implied by the byte gate but INFERRED on an
/// OPEN remap (module doc) — out until a capture confirms.
/// The CVar gating this kind's bubble (`0x608b0d`); `None` for a kind that does not bubble.
fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option<bool> {
use ChatEventKind as K;
match kind {
@@ -162,7 +100,7 @@ fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option<bool> {
}
}
/// The space/tab-run word counter (`0x4b1810`): maximal runs of non-space/tab characters.
/// The word count (`0x4b1810`): maximal runs of characters other than space and tab.
fn word_count(text: &str) -> u32 {
let mut words = 0u32;
let mut in_word = false;
@@ -176,8 +114,8 @@ fn word_count(text: &str) -> u32 {
words
}
/// The duration law (`0x4b1810`): `base + perWord·(words−1)` ms — 2750/750 for someone else,
/// 1500/500 for the local player (your own bubbles are shorter-lived). Empty → 0.
/// The lifetime (`0x4b1810`): `base + perWord·(words−1)` ms, 2750/750 for others and 1500/500
/// for the local player.
fn duration_secs(words: u32, is_self: bool) -> f32 {
if words == 0 {
return 0.0;
@@ -186,10 +124,8 @@ fn duration_secs(words: u32, is_self: bool) -> f32 {
(base + per * (words - 1)) as f32 / 1000.0
}
/// The bubble-text sanitize: `||` stays an escaped literal pipe, `|cAARRGGBB`/`|r` color
/// escapes and the `|H…|h`/`|h` hyperlink wrappers strip (their display text stays) — bubble
/// text is always plain, colored solely by chatType. Output remains in ESCAPED form (literal
/// pipes as `||`) because the glyph layout's own markup parser consumes it downstream.
/// Bubble text is plain, coloured only by chat type: colour escapes and hyperlink wrappers strip
/// (their display text stays), and `||` stays escaped for the glyph layout's markup parser.
fn sanitize(text: &str) -> String {
let b = text.as_bytes();
let mut out = String::with_capacity(text.len());
@@ -212,8 +148,7 @@ fn sanitize(text: &str) -> String {
Some(b'c') | Some(b'C') if i + 10 <= b.len() => i += 10,
Some(b'r') | Some(b'R') => i += 2,
Some(b'H') => {
// Skip the opener through its `|h`; the display text then flows until the
// closing `|h`, which the arm below drops.
// Skip the opener through its `|h`; the closing `|h` drops in the arm below.
i += 2;
while i < b.len() && !(b[i] == b'|' && b.get(i + 1) == Some(&b'h')) {
i += 1;
@@ -221,7 +156,7 @@ fn sanitize(text: &str) -> String {
i += 2;
}
Some(b'h') => i += 2,
// A dangling or unknown escape renders as a literal pipe + the rest.
// A dangling or unknown escape renders as a literal pipe.
_ => {
out.push_str("||");
i += 1;
@@ -231,8 +166,7 @@ fn sanitize(text: &str) -> String {
out
}
/// The bleed inset for one border piece, `Vec2::ZERO` texels meaning "art size unknown, leave the
/// UVs alone" (a bare test app with no patch chain).
/// The bleed inset for one border piece; zero texels (no patch chain) leaves the UVs alone.
fn pieces_inset(uvs: [[f32; 2]; 4], texels: Vec2) -> [[f32; 2]; 4] {
if texels.x <= 0.0 || texels.y <= 0.0 {
return uvs;
@@ -240,15 +174,8 @@ fn pieces_inset(uvs: [[f32; 2]; 4], texels: Vec2) -> [[f32; 2]; 4] {
inset_atlas_bleed(uvs, texels.x, texels.y)
}
/// The frame's bottom-left origin for a projected seat: BOTTOM-CENTER on the seat, then both
/// coordinates snapped onto the **device** pixel grid ([`device_snap`], the plate's law).
///
/// Split out so the snap law is nameable and pinned. It shipped as a plain logical `round()`,
/// which is 1 device pixel only at scale 1: on the 2× display we play on it stepped the bubble
/// two physical pixels per axis over a continuously-sliding world, and at a fractional scale
/// (1.25/1.5) it never landed on a texel boundary at all — so it paid the stepping without even
/// buying the crispness it exists for. Same bug, same fix as the plate (0188's snap → the plate's
/// device grid); the bubble was the site that never got it (1398).
/// The frame's bottom-left origin, bottom-center on the seat. Deviation: snapped onto the device
/// pixel grid ([`device_snap`]) so the border art blits 1:1; the reference seats it fractionally.
fn seat_origin(seat: Vec2, w: f32, scale: f32) -> Vec2 {
Vec2::new(
device_snap(seat.x - w * 0.5, scale),
@@ -256,23 +183,18 @@ fn seat_origin(seat: Vec2, w: f32, scale: f32) -> Vec2 {
)
}
/// The border-unit in logical px for this viewport: 16/1024 of the screen width, carried
/// through the damped size basis (so it shrinks in step with plate/text sizes past the knee).
/// The border unit in logical px: 16/1024 of the screen width, in the damped size basis.
fn border_px(viewport: Vec2, basis: f32) -> f32 {
let width_gx = viewport.x / viewport.length();
gx_px(width_gx * BORDER_FRAC, basis).max(1.0)
}
/// The bubble-spawn requests this frame — fed by the chat feed's wire arm (the reference
/// spawns in the SMSG display path, the same moment the line routes) and drained by
/// [`drive_bubbles`]. Push-side filtered so guild/system spam never queues strings.
/// This frame's bubble-spawn requests, pushed as the chat line routes.
#[derive(Resource, Default)]
pub(crate) struct BubbleQueue(Vec<(u64, ChatEventKind, String)>);
impl BubbleQueue {
/// Queue a routed wire line for a bubble. Drops non-bubbling kinds, disabled CVars, and
/// senderless lines here; the live-unit/range/plate gates run in the driver. The switch is
/// read at PUSH time, so a Chat-page click takes the very next line either way.
/// Queue a routed line; kind, CVar and sender filter here, unit, plate and range in the driver.
pub(crate) fn push(
&mut self,
cfg: &BubbleConfig,
@@ -299,45 +221,37 @@ impl BubbleQueue {
}
}
/// One live bubble (the `CGChatBubbleFrame` + its `CGUnit+0xe64` handle, folded together).
/// One live bubble: the `CGChatBubbleFrame` and its `CGUnit+0xe64` handle.
struct Bubble {
/// Sanitized display text ([`sanitize`] — plain, escapes stripped).
/// The [`sanitize`]d display text.
text: String,
/// The chatType color (the 94-entry table), client-space sRGB 0..1.
/// The chat-type colour (the 94-entry table), sRGB 0..1.
color: [f32; 3],
/// `Time::elapsed_secs` at spawn.
born: f32,
/// The steady window past the fade-in ([`duration_secs`]).
/// The steady window after the fade-in ([`duration_secs`]).
duration: f32,
/// The **latched** anchor height above the speaker's feet: the Stand box's Z extent
/// ([`crate::entities::StandBoxHeight`]) already multiplied by the unit's model scale, queried
/// ONCE here and never re-read — the reference caches exactly this product at `bubble+0x354`
/// behind a parity guard, so it is one query per chat line (1406).
/// Stand-box height × model scale, latched at spawn as the reference does (`bubble+0x354`).
lift: f32,
/// Current fade alpha 0..1 — ramped 250 ms linear toward the eligibility verdict.
/// Fade alpha 0..1, ramped toward the eligibility verdict.
alpha: f32,
}
/// The live bubbles, keyed by speaker guid (a unit carries at most one — replace, never queue).
/// The live bubbles by speaker guid, at most one per unit.
#[derive(Resource, Default)]
struct Bubbles(HashMap<u64, Bubble>);
/// The units carrying a live bubble this frame — the `ShouldShowName` exclusivity verdict the
/// overhead-name driver reads (`+0xe64` ≠ 0 suppresses the floating name), the mirror of
/// [`VPlates`].
/// Units with a live bubble this frame, whose overhead name is suppressed (`+0xe64` ≠ 0).
#[derive(Resource, Default)]
pub(crate) struct BubblesActive(pub(crate) EntityHashSet);
/// The bubble art, warmed at boot like the plate art (a first bubble must not draw texture-less).
/// The edge strip needs REPEAT addressing (its runs tile UVs past 1); bg and tail clamp.
/// The bubble art, loaded at boot. The edge strip tiles UVs past 1 (repeat); bg and tail clamp.
#[derive(Resource)]
struct BubbleArt {
bg: Handle<Image>,
edge: Handle<Image>,
tail: Handle<Image>,
/// The edge atlas's size in texels, for the half-texel bleed inset
/// ([`benilla_ui::script::backdrop::inset_atlas_bleed`] — 1402). Stamped at load because the
/// draw has no `Assets<Image>` and this never changes.
/// The edge atlas's size in texels, for the half-texel bleed inset.
edge_texels: Vec2,
}
@@ -347,7 +261,7 @@ fn load_bubble_art(
mut images: ResMut<Assets<Image>>,
) {
let Some(mut assets) = assets else {
return; // no game data (bare test app) — drive_bubbles tolerates the missing resource
return; // no game data (a bare test app); `drive_bubbles` tolerates the missing art
};
let bg = assets.sprite_texture(BG_TEXTURE, &mut images);
let edge = assets.sprite_texture_tiled(EDGE_TEXTURE, &mut images);
@@ -368,12 +282,8 @@ fn load_bubble_art(
});
}
/// Spawn, tick, and draw, every frame: drain the queue through the spawn gate (`0x608ac0`),
/// ramp each live bubble's fade against its lifetime + the per-frame 20 yd re-test
/// (`0x4b0c30`), publish the name-suppression verdict, and append the draw list — backdrop
/// pieces, tail, wrapped chatType-colored text — bottom-seated on the projected anchor.
/// Runs after [`VPlateSet`] (the spawn gate reads this frame's plate verdict), inside the
/// [`UiQuadAppend`] window.
/// Every frame: drain the queue through the spawn gate (`0x608ac0`), ramp each bubble's fade
/// against its lifetime and the 20 yd re-test (`0x4b0c30`), publish [`BubblesActive`], and draw.
#[allow(clippy::type_complexity)] // one Bevy system's full input set
fn drive_bubbles(
mut queue: ResMut<BubbleQueue>,
@@ -387,26 +297,20 @@ fn drive_bubbles(
mut atlas: Option<ResMut<UiFontAtlas>>,
mut quads: ResMut<UiQuads>,
art: Option<Res<BubbleArt>>,
// The latched anchor height, read once per bubble at spawn ([`StandBoxHeight`]).
heights: Query<&StandBoxHeight>,
time: Res<Time>,
// The device scale, for the seat's pixel snap ([`device_snap`]).
// The device scale, for the seat's pixel snap.
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
) {
active.0.clear();
let now = time.elapsed_secs();
let step = time.delta_secs() / FADE_SECS;
let self_tf = self_q.single().ok();
// The typed sender lookup (`0x468460`): guid → live unit. Linear over the streamed set,
// like the plate walk — both populations are 20 yd-bounded and small.
// The typed sender lookup (`0x468460`), a linear scan of the small streamed set.
let find = |guid: u64| units.iter().find(|(_, g, _)| g.0 == guid);
// ── The spawn/replace gate (`0x608ac0`) ─────────────────────────────────────────────────
// Every arm below reports its REFUSAL under the `bub` tag. A bubble that never appears used to
// be a silent five-way question — no text, no unit, a plate in the way, no local player, out of
// range — with nothing in any log to separate them, and the draw trace beside it says nothing
// because the draw never runs. One line per refusal is the difference between reading the
// answer and bisecting for it.
// Every refusal is traced under the `bub` tag.
let refuse = |why: &str, guid: u64| {
if benilla_assets::trace::enabled_for("bub") {
benilla_assets::trace::line("bub", &format!("refuse guid={guid:#x} {why}"));
@@ -417,37 +321,32 @@ fn drive_bubbles(
let words = word_count(&text);
if words == 0 {
refuse("empty-text", guid);
continue; // empty text → duration 0 → no bubble
continue; // no words, no lifetime, no bubble
}
let Some((entity, _, tf)) = find(guid) else {
refuse("sender-not-a-live-unit", guid);
continue; // sender not resolvable to a live unit → NO bubble
continue;
};
// An active V-plate blocks bubble creation (`0x608adc` — the mutual exclusion,
// faithful; restored by 0599 after 0598 briefly stacked them). Friendly plates boot
// OFF (0599's other half), so friendly/party bubbles have room; a plated hostile
// yelling shows no bubble, exactly like the reference with plates toggled on.
// An active V-plate blocks the bubble (`0x608adc`): a plated hostile's yell shows none.
if vplates.0.contains(&entity) {
refuse("v-plate-on-speaker", guid);
continue;
}
let Some(self_tf) = self_tf else {
refuse("no-local-player", guid);
continue; // the local player must resolve
continue;
};
let d2 = (tf.translation - self_tf.translation).length_squared();
if d2 > MAX_DIST_SQ {
refuse(&format!("out-of-range dist={:.1}yd", d2.sqrt()), guid);
continue; // 20 yd at spawn
continue;
}
let is_self = self_guid.0 == Some(guid);
let c = default_color(kind);
// The one-time attachment query (`0x4b0e38 call 0x711a20`, scaled by `[unit+0x90]`): a file
// constant × this unit's model scale. A speaker with no bounds reads 0 and the bubble sits
// at the feet + 0.7, which is the reference's own degenerate for a bounds-less model.
// The one-time height query (`0x4b0e38` calling `0x711a20`, scaled by `[unit+0x90]`). A
// model with no bounds reads 0 and the bubble sits 0.7 above the feet, as in the reference.
let lift = heights.get(entity).map_or(0.0, |h| h.0) * tf.scale.y;
// Replace, never queue: the insert tears the old bubble down (`0x608c00`) and the
// fresh one fades in from 0.
// Replace, never queue (`0x608c00`): the fresh bubble fades in from 0.
bubbles.0.insert(
guid,
Bubble {
@@ -477,20 +376,18 @@ fn drive_bubbles(
let mut pending: Vec<Pending> = Vec::new();
for (guid, b) in bubbles.0.iter_mut() {
let Some((entity, _, tf)) = find(*guid) else {
dead.push(*guid); // the speaker despawned — the unit teardown takes its bubble
dead.push(*guid); // the speaker despawned, and its bubble with it
continue;
};
if now >= b.born + b.duration + FADE_SECS {
// The permanent fade-out (`0x4b0ee0(1)`) — at 0 the frame recycles.
// The permanent fade-out (`0x4b0ee0(1)`); at 0 the frame recycles.
b.alpha -= step;
if b.alpha <= 0.0 {
dead.push(*guid);
continue;
}
} else {
// Per-frame re-eligibility: the same 20 yd gate, recoverable — out of range fades
// out, back in range fades back in. (The client also fades on the speaker model's
// render visibility — the v1 residual, module doc.)
// The 20 yd gate, re-tested every frame: out of range fades out, back in fades in.
let eligible = self_tf
.is_some_and(|s| (tf.translation - s.translation).length_squared() <= MAX_DIST_SQ);
b.alpha = if eligible {
@@ -499,28 +396,19 @@ fn drive_bubbles(
(b.alpha - step).max(0.0)
};
}
// The live handle (`+0xe64` ≠ 0): the name suppression holds while the bubble EXISTS,
// faded or not.
// The name stays suppressed while the bubble exists, faded or not (`+0xe64` ≠ 0).
active.0.insert(entity);
if b.alpha <= 0.0 {
continue;
}
// No camera/atlas/art — lifetimes still tick, nothing draws.
// No camera, atlas or art: lifetimes tick, nothing draws.
let (Some((cam, cam_pose)), true) = (cam, atlas.is_some() && art.is_some()) else {
continue;
};
// World anchor (`0x4b0c30`): the unit's position, Z lifted by the LATCHED Stand-box height
// + 0.7 yd — seated BOTTOM at the projected point, growing upward. Every term is this
// frame's `Transform` or a constant, so there is no pose to read and no clock to get wrong.
// A point behind the camera draws nothing (state kept — it fades back the moment it
// projects again).
// The anchor (`0x4b0c30`), from this frame's `Transform` and constants only. A seat behind
// the camera draws nothing and keeps its state.
let seat_world = tf.translation + Vec3::Y * (b.lift + LIFT);
let cam_tf = GlobalTransform::from(*cam_pose);
// The SEVENTH silent cause, and the one that cost the most to find: a seat behind the
// camera fails to project and the bubble draws nothing — alive, ticking, invisible, and
// (before this) invisible to the trace too, because every `bub` line is written by the
// draw. A probe whose camera had been restored pitched into the ground therefore read as
// "bubbles are broken" for six runs (1402). Reported like the spawn gate's refusals.
let Ok(seat) = cam.world_to_viewport(&cam_tf, seat_world) else {
refuse("not-on-screen (behind the camera)", *guid);
continue;
@@ -529,15 +417,13 @@ fn drive_bubbles(
refuse("no-viewport", *guid);
continue;
};
// Seated and projected here, DRAWN below: the level a bubble draws at is a property of
// the whole live set, not of this bubble ([`level_z`]), so the draw waits for the sort.
// Drawn after the sort: a bubble's level depends on the whole live set ([`level_z`]).
pending.push(Pending {
guid: *guid,
entity,
seat,
viewport,
// The sort key (`0x4b1251`–`0x4b12c0`): |camera − speaker|², the CAMERA's distance,
// not the local player's (which is the eligibility gate's, above).
// The sort key (`0x4b1251`-`0x4b12c0`): the camera's distance, not the player's.
cam_dist_sq: cam_pose.translation.distance_squared(tf.translation),
anchor: seat_world,
unit_pos: tf.translation,
@@ -548,15 +434,11 @@ fn drive_bubbles(
}
// ── The frame-level pass (`0x4b12d5`–`0x4b1312`) ─────────────────────────────────────────
// The reference re-sorts its live-bubble list by camera distance, FARTHEST at the head, and
// then walks head→tail stamping `SetFrameLevel(2), (3), (4)…` — so every bubble owns a frame
// level of its own and the NEAREST speaker's bubble ends up on top. (It only re-sorts when
// the camera moved or a bubble is dirty; the stamping walk runs unconditionally. Sorting
// every frame is the same list with no state to keep.) Ties break on guid so an exact
// distance tie can't flip the stack frame to frame.
// Sorted farthest first (`0x4b1360`), levels stamped head to tail (`0x4b1309`). The reference
// re-sorts only on a change; sorting every frame gives the same list.
stack_sort(&mut pending);
let (Some((_, cam_pose)), Some(atlas), Some(art)) = (cam, atlas.as_deref_mut(), art) else {
return; // nothing reached `pending` either — the same gate refused it in the tick
return; // the tick's same gate kept `pending` empty
};
for (rank, p) in pending.iter().enumerate() {
let Some(b) = bubbles.0.get(&p.guid) else {
@@ -580,10 +462,8 @@ fn drive_bubbles(
}
}
/// The stack order: **farthest camera distance first**, so the enumeration index that follows is
/// the reference's frame level and the nearest speaker's bubble draws last (on top). Ties break on
/// guid — the reference's list keeps its previous order across an exact tie because it re-inserts
/// only ahead of a *strictly* closer node, and a `HashMap` walk has no such memory.
/// Farthest camera distance first, so the index is the frame level. Ties break on guid, as the
/// reference keeps its order across an exact tie and a `HashMap` walk cannot.
fn stack_sort(pending: &mut [Pending]) {
pending.sort_by(|a, b| {
b.cam_dist_sq
@@ -592,21 +472,20 @@ fn stack_sort(pending: &mut [Pending]) {
});
}
/// One bubble that passed the tick and projected on screen, waiting for the frame-level sort.
/// One projected bubble awaiting the frame-level sort.
struct Pending {
guid: u64,
entity: Entity,
seat: Vec2,
viewport: Vec2,
/// |camera − speaker|², the reference's own sort key (`[bubble+0x350]`).
/// |camera − speaker|², the reference's sort key (`[bubble+0x350]`).
cam_dist_sq: f32,
/// The world seat and the speaker's position — the `bub` jitter trace's decomposition.
/// The world seat and the speaker's position, for the `bub` trace.
anchor: Vec3,
unit_pos: Vec3,
}
/// Append one bubble's draw list: the Backdrop pieces (bg fill inset by the border-unit +
/// the 8-piece edge), the tail square, and the wrapped, centered, chatType-colored text.
/// Append one bubble's draw list: the backdrop pieces, the tail and the wrapped, centered text.
fn draw_bubble(
atlas: &mut UiFontAtlas,
quads: &mut UiQuads,
@@ -616,35 +495,31 @@ fn draw_bubble(
viewport: Vec2,
scale: f32,
trace: bool,
// The `bub` jitter-decomposition trace's inputs — see the tail of this function.
// The `bub` trace's inputs.
entity: Entity,
anchor: Vec3,
unit_pos: Vec3,
cam_pose: &Transform,
// This bubble's frame level, flattened to a z base ([`level_z`]).
// This bubble's frame level as a z base.
z: u64,
) {
let basis = plate_basis(viewport);
let border = border_px(viewport, basis);
let margin = gx_px(MARGIN, basis);
// The exact window-derived em, laid out AT that em. This used to shape at the nearest baked
// ladder size and rescale the finished quads by `px/shaped` around the box centre — a bubble's
// size is derived from the viewport and so lands on nothing round, which meant every bubble on
// screen was a resampled bitmap. Since decision 1342 the raster follows the request.
// Shaped at the exact window-derived em, so the glyphs are never a rescaled bitmap.
let px = text_px(TEXT_H, basis);
if px <= 0.0 {
return;
}
let mut e = atlas.lock();
let spec = FontSpec {
path: None, // NAMEPLATE_FONT — Friz Quadrata, the engine's default face
path: None, // `NAMEPLATE_FONT`: Friz Quadrata, the engine's default face
height: Some(px),
outline: Outline::None,
alpha_gradient: None,
};
// The wrap law (`0x4b1600` tail): single-line width > 0.2 gx → hard-cap (forces wrap);
// else max(measured, 2·border-unit).
// The wrap law (`0x4b1600`): wider than 0.2 gx wraps at the cap, else max(width, 2 borders).
let cap = gx_px(WRAP_W, basis);
let floor = 2.0 * border;
let (line_w, _) = measure_text(&mut e, &b.text, None, spec);
@@ -652,14 +527,7 @@ fn draw_bubble(
let (_, box_h) = measure_text(&mut e, &b.text, Some(box_w), spec);
let (text_w, text_h) = (box_w.ceil(), box_h.ceil());
// The auto-fit body: the frame hugs the text layout ± the flat margin; BOTTOM-center on
// the seat, growing upward. Snapped onto the **device** pixel grid (the plate divergence —
// a fractional corner bilinear-smears the border art), which is [`device_snap`], the plate's
// own law, not the plain logical `round()` this shipped with: the quad lane is logical px, so
// rounding there quantized the bubble to `scale_factor` PHYSICAL pixels — two pixels of
// stepping per axis on the 2× display we play on, against a world sliding continuously
// underneath, and no texel alignment at all at a fractional scale. It is the same bug the
// plate was carrying and the same fix; the bubble is simply the site that never got it (1398).
// The frame hugs the text layout plus the margin, bottom-center on the seat, growing upward.
let w = text_w + 2.0 * margin;
let h = text_h + 2.0 * margin;
let origin = seat_origin(seat, w, scale);
@@ -667,8 +535,8 @@ fn draw_bubble(
let frame = Rect::new(left, bottom - h, left + w, bottom);
let alpha = b.alpha;
// The Backdrop construct (`0x4b0a35 → 0x76a5d0`): one value feeds the 4 insets and both
// edge-size fields. The engine's `pieces` speaks y-up — negate y across the seam.
// The backdrop (`0x4b0a35` into `0x76a5d0`): one value feeds the four insets and both
// edge-size fields. `pieces` is y-up, so y negates across the seam.
let bd = Backdrop {
bg_file: Some(BG_TEXTURE.to_string()),
edge_file: Some(EDGE_TEXTURE.to_string()),
@@ -686,18 +554,15 @@ fn draw_bubble(
};
let up = GxRect::new(-frame.max.y, frame.min.x, -frame.min.y, frame.max.x);
for p in pieces(up, &bd) {
// Every bubble piece is axis-aligned (equal insets make the BR-inset quirk vacuous):
// corners [TL,TR,BR,BL] y-up → a y-down rect from TL/BR, UVs riding their corners.
// Equal insets keep every piece axis-aligned: a y-down rect from its TL and BR corners.
let rect = Rect::new(
p.corners[0][0],
-p.corners[0][1],
p.corners[2][0],
-p.corners[2][1],
);
// The border pieces share one 256×32 atlas; without the half-texel inset, bilinear at each
// piece's own edge blends in the neighbour's first column — and the column beside the TOP
// slice is WHITE at alpha 0, which is the pale line that ran through the bubble (1402). The
// bg is its own texture and keeps its UVs.
// The border pieces share one 256×32 atlas: without the half-texel inset, bilinear blends
// in the neighbour's column, white at alpha 0 beside the top slice, a pale line.
let uvs = if p.is_bg {
p.uvs
} else {
@@ -716,8 +581,7 @@ fn draw_bubble(
..default()
});
}
// The tail (`0x4b0af1`): a border-unit square, TOPRIGHT on the frame's BOTTOM lifted
// border/4 INTO the body — the overlap that makes the seam read continuous.
// The tail (`0x4b0af1`): a border-unit square, TOPRIGHT on the bottom lifted border/4.
let tail_top = frame.max.y - border * 0.25;
let cx = (frame.min.x + frame.max.x) * 0.5;
quads.overlays.push(UiQuad {
@@ -728,8 +592,7 @@ fn draw_bubble(
color: [1.0, 1.0, 1.0, alpha],
..default()
});
// The text: centered in the margin box, wrapped at the cap. No rescale pass — the glyphs came
// out of the cache at this em.
// The text, centered in the margin box and wrapped at the cap.
let center = Vec2::new(cx, (frame.min.y + frame.max.y) * 0.5);
let mut text_quads = layout_text_quads(
&mut e,
@@ -742,12 +605,7 @@ fn draw_bubble(
},
z + Z_TEXT,
spec,
// The bubble frame seats on the DEVICE pixel grid ([`device_snap`], 1398) and slides with
// the speaker; its text must be rigid against it. Snapping the block top on the UI's
// logical grid instead made the text pop a whole px every second step the frame took —
// the V-plate's defect at the sibling site, exactly as 1398 found the snap itself missing
// here. Unlike the plate this rect is real (a margin box), so the degenerate-rect
// carve-out never covered it.
// Exact, so the text stays rigid against the device-snapped frame.
TextSeat::Exact,
);
drop(e);
@@ -757,19 +615,9 @@ fn draw_bubble(
frame.min.x, frame.min.y, frame.max.x, frame.max.y, b.text
);
}
// **The jitter decomposition** (`WOW_MOVE_TRACE` tag `bub`, one line per bubble per frame) —
// the plate's `vpl` line ([`crate::vplates`]) for the bubble: the seat's world point, the
// camera pose that projected it, the raw projected seat, and the snapped frame origin. "The
// bubble is jittery when running" is then attributed from numbers rather than from the eye —
// a moving anchor, a noisy camera, or the pixel snap. Since 1406 `anchor=` and `pos=` differ by
// a LATCHED constant on Y and by nothing at all on X/Z, so any wobble between them is a defect
// by construction. (Through 1398 they were read on different clocks — the anchor computed
// through a `GlobalTransform` Bevy propagates in `PostUpdate`, i.e. last frame's — and their
// difference measured exactly that lag: the term 1341 cleared for the plate by measuring a unit
// that was STANDING STILL, where it is identically zero. Dropping the pose read removes the
// seam rather than correcting it.) It rides the shared tag-filtered trace and NOT the `WOW_BUBBLE_TRACE`
// eprintln beside it, whose unbuffered writes would distort the frame pacing the question is
// about (the 0880 lesson, the same reason `vpl` lives there).
// The jitter decomposition (`WOW_MOVE_TRACE` tag `bub`): `anchor=` and `pos=` differ by a
// latched constant on Y only, so a wobble between them is a defect. Not the eprintln above,
// whose unbuffered writes would distort frame pacing.
if benilla_assets::trace::enabled_for("bub") {
let (cp, cf) = (cam_pose.translation, cam_pose.forward());
benilla_assets::trace::line(
@@ -803,14 +651,13 @@ fn draw_bubble(
quads.overlays.append(&mut text_quads);
}
/// The bubble stage — [`crate::nameplates`] orders after it (the bubble/name exclusion reads
/// [`BubblesActive`]), exactly as it orders after [`VPlateSet`] for the plate verdict.
/// The bubble stage; [`crate::nameplates`] orders after it to read [`BubblesActive`].
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct BubbleSet;
pub(crate) struct ChatBubblePlugin;
/// The two bubble switches' change callback (1139, 2303): flags, like every other pair.
/// The two bubble CVars' change callback.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut bubbles: ResMut<BubbleConfig>) {
match ev.key().as_str() {
"chatbubbles" => bubbles.all = ev.flag(),
@@ -827,9 +674,7 @@ impl Plugin for ChatBubblePlugin {
.init_resource::<Bubbles>()
.init_resource::<BubblesActive>()
.add_systems(Startup, load_bubble_art.after(AssetSet::Open))
// After the V-plate drive (the spawn gate reads this frame's plate verdict),
// inside the UI-quad append window; the name driver orders after [`BubbleSet`]
// (the bubble/name exclusion reads this frame's verdict).
// After the V-plate drive, whose verdict the spawn gate reads.
.add_systems(
Update,
drive_bubbles
@@ -844,7 +689,7 @@ impl Plugin for ChatBubblePlugin {
mod tests {
use super::*;
/// The word counter is a space/tab-run law — not Unicode whitespace, not collapse-free.
/// Space and tab runs only, not Unicode whitespace.
#[test]
fn word_count_is_the_space_tab_run_law() {
assert_eq!(word_count(""), 0);
@@ -854,7 +699,6 @@ mod tests {
assert_eq!(word_count("a\u{a0}b"), 1, "NBSP is not a separator");
}
/// The duration bytes: others 2750 + 750·(n−1), self 1500 + 500·(n−1), empty 0.
#[test]
fn duration_matches_the_byte_law() {
assert_eq!(duration_secs(0, false), 0.0);
@@ -864,8 +708,6 @@ mod tests {
assert_eq!(duration_secs(3, true), 2.5);
}
/// Bubble text is plain: color/hyperlink escapes strip (display text stays), `||` stays
/// escaped for the downstream markup parser, a dangling pipe renders literally.
#[test]
fn sanitize_strips_escapes_keeps_display_text() {
assert_eq!(sanitize("hello"), "hello");
@@ -879,17 +721,7 @@ mod tests {
assert_eq!(sanitize("|x odd"), "||x odd");
}
/// The v1 kind set: say/yell + monster say/yell on `ChatBubbles`, party on
/// `ChatBubblesParty`; everything else out pending the OPEN remap capture. And the two
/// switches are genuinely separate: the client gates party lines on their own CVar, so
/// turning one off leaves the other bubbling.
///
/// The sweep runs on a config with **both switches planted on**, which is not what a fresh
/// client boots at: `ChatBubblesParty` shipped `"1"` from 0598's `/p` ask until 1804 put both
/// defaults back on the binary's (`ChatBubbles` `"1"`, `ChatBubblesParty` `"0"`). Planted,
/// because the question here is which *kinds* the gate admits, and a kind whose switch is off
/// cannot answer it. The shipped pair is asserted below, where it is exactly the
/// party-off half of the independence check.
/// Both switches on, since a kind whose switch is off cannot show whether the gate admits it.
#[test]
fn the_kind_set_is_the_uncontested_v1() {
use ChatEventKind as K;
@@ -918,8 +750,7 @@ mod tests {
assert_eq!(bubble_cvar(k, &on), None, "{k:?} must not bubble in v1");
}
// Party off with say on — which is the SHIPPED pair since 1804, so this half of the
// independence check is [`BubbleConfig::default`] itself and pins the boot state too.
// Party off with say on is the default pair, so this also pins the boot state.
let no_party = BubbleConfig::default();
assert!(no_party.all && !no_party.party, "the shipped pair");
assert_eq!(
@@ -944,11 +775,7 @@ mod tests {
);
}
/// The seat snaps on the DEVICE grid, not the logical one — the plate's law
/// ([`device_snap`]), which the bubble shipped without. At the 2× display we play on, a
/// logical `round()` moved the bubble two physical pixels per axis against a world that
/// slides continuously; this moves it one, the smallest step that still lands the border
/// blit on a texel boundary. Pinned so `scale` can't be "simplified" back out into `round()`.
/// A logical `round()` would step two physical pixels per axis at 2×.
#[test]
fn the_bubble_seat_snaps_on_the_device_grid() {
// 2×: the grid is every half logical pixel, and every snapped edge is a whole physical px.
@@ -962,15 +789,14 @@ mod tests {
o.y
);
}
// The x half is the same law applied to the CENTERED left edge (seat − w/2), so an odd
// width still lands the left edge — the one the border blit starts from — on the grid.
// The x half snaps the centered left edge (seat − w/2), where the border blit starts.
let o = seat_origin(Vec2::new(100.4, 0.0), 41.0, 2.0);
assert_eq!(o.x, 80.0);
assert_eq!(((100.4_f32 - 20.5) * 2.0).round() / 2.0, o.x);
// 1×: identical to the logical round() it replaces — the fix costs nothing at scale 1.
// 1×: identical to a logical round().
assert_eq!(seat_origin(Vec2::new(0.0, 10.4), 0.0, 1.0).y, 10.0);
assert_eq!(seat_origin(Vec2::new(0.0, 10.6), 0.0, 1.0).y, 11.0);
// 1.5× (the Windows norm), where a logical round() was never texel-aligned at all.
// 1.5×, where a logical round() is never texel-aligned.
for v in [10.4, 10.9, 11.2] {
let o = seat_origin(Vec2::new(0.0, v), 0.0, 1.5);
assert_eq!(
@@ -981,10 +807,7 @@ mod tests {
}
}
/// The snap must not be a *quantizer with a large step*: over a continuous glide, the extra
/// displacement it adds to any one frame is bounded by half a device pixel. This is the
/// property the jitter report is about — at 2× the old logical round() allowed a whole
/// logical pixel (two physical) of extra step, which is what read as judder.
/// Over a continuous glide the snap displaces any one frame by at most half a device pixel.
#[test]
fn the_snap_adds_at_most_half_a_device_pixel_of_step() {
let scale = 2.0;
@@ -1001,24 +824,20 @@ mod tests {
);
}
/// Every bubble owns a frame LEVEL: its four pieces are one contiguous band, bands ascend
/// with rank, and no two bubbles can interleave. This is the whole 1504 fix — with a flat
/// per-piece key, every bubble's background painted under every bubble's text, so a speaker's
/// line bled across a neighbour's card.
/// One contiguous band per bubble, ascending with rank, so no two bubbles interleave.
#[test]
fn each_bubble_gets_its_own_level_band() {
// The four pieces of one bubble, in paint order, all inside one level.
const { assert!(Z_BG < Z_EDGE && Z_EDGE < Z_TAIL && Z_TAIL < Z_TEXT) };
const { assert!(Z_TEXT < overlay_z::BUBBLE_STRIDE) };
for rank in 0..8usize {
// ...and every piece strictly below the NEXT bubble's background.
// ...and every piece strictly below the next bubble's background.
assert!(level_z(rank) + Z_TEXT < level_z(rank + 1) + Z_BG);
}
assert!(level_z(1) > level_z(0), "later rank draws later");
}
/// The band is bounded: however many bubbles are live, none of them can reach the V-plates'
/// band above ([`crate::ui_pass::overlay_z`]) or the floating combat text's below.
/// However many bubbles are live, none reaches the V-plates above or the combat text below.
#[test]
fn the_bubble_band_stays_between_its_neighbours() {
for rank in [0, 1, 1_000, usize::MAX] {
@@ -1028,8 +847,7 @@ mod tests {
}
}
/// The stack order is the reference's: farthest camera distance first (so the nearest
/// speaker's bubble ends up on top), guid breaking an exact tie deterministically.
/// Farthest camera distance first, guid breaking an exact tie.
#[test]
fn the_stack_is_sorted_farthest_first() {
let at = |guid: u64, d2: f32| Pending {
@@ -1048,12 +866,11 @@ mod tests {
vec![2, 3, 7, 9],
"farthest first; the 400-yd² pair ordered by guid"
);
// The rank the draw uses IS the frame level: the nearest speaker draws last.
// The rank the draw uses is the frame level: the nearest speaker draws last.
assert!(level_z(0) < level_z(p.len() - 1));
}
/// The border-unit lands the byte constants: 16 px at 1024-wide 4:3 (G44·16/(S·1024)
/// nets width/64), damped past the plate knee like every other size.
/// 16 px at 1024-wide 4:3 (width/64), damped past the plate knee.
#[test]
fn border_unit_is_a_64th_of_the_width() {
let vp = Vec2::new(1024.0, 768.0);

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