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:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+257
-528
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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..];
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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})");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user