Merge upstream main (samwhosung/benilla fd386e75) into lighting
71 upstream commits: the repo-wide comment rewrite, the hover highlight as the scene ambient, M2 Mod/Mod2x batches drawing the bare texel, test and contribution changes. Conflicts were comment-vs-code in 36 files; where upstream changed only comments our code was kept, and wow_model.wgsl carries both our lighting lanes and upstream's new unlit/Mod/Mod2x paths. Follow-ups folded in: - our nine Advanced Graphics / water / fog options tests take upstream's wow_data_or_skip! guard - PostUpdate ambiguity ceiling re-measured under upstream's new census: 466, with the per-family breakdown in the comment - LIGHTING.md: new upstream base Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# `cargo play` — the run loop. Same optimization as `--release`, plus incremental compilation (see
|
||||
# `[profile.play]` in the workspace Cargo.toml for why it is a separate profile and not a flip of
|
||||
# `release`). Env vars still prefix it: `WOW_USER=one WOW_PASS=pone cargo play`.
|
||||
[alias]
|
||||
play = "run --profile play -p benilla"
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Bug report
|
||||
description: Something benilla gets wrong, or does differently from 1.12.1.
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: Screenshots or a clip help a lot; drag them in here.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What 1.12.1 does instead
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: How to see it
|
||||
placeholder: Where you were and what you did.
|
||||
- type: input
|
||||
id: build
|
||||
attributes:
|
||||
label: Build and setup
|
||||
description: The `benilla build` line from the start of the terminal output, your OS, and the server you play on.
|
||||
placeholder: "benilla build 64cca13 · 2026-09-24 · release, macOS 15, vmangos"
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/wJSJx467G4
|
||||
about: Questions, ideas and chat.
|
||||
@@ -0,0 +1,7 @@
|
||||
<!-- One change per pull request. docs/CONTRIBUTING.md says what gets in. -->
|
||||
|
||||
**What this changes**
|
||||
|
||||
**What 1.12.1 does, and how you know** (the client's behaviour, a DBC field, a FrameXML line, a capture)
|
||||
|
||||
**How you checked it** (`scripts/check.sh` or `scripts/gates.sh`, and before and after)
|
||||
@@ -1,21 +0,0 @@
|
||||
# GitHub has no switch to disable pull requests, so this closes them on arrival.
|
||||
# Uses pull_request_target (base-repo context, no PR code checked out) so it can
|
||||
# comment and close on PRs from forks.
|
||||
name: close-prs
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
permissions:
|
||||
pull-requests: write
|
||||
jobs:
|
||||
close:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
gh pr comment "$PR" --repo "$REPO" --body \
|
||||
"Thanks for the interest! benilla is a solo project and isn't taking pull requests for now — please bring bug reports and ideas to the Discord instead: https://discord.gg/wJSJx467G4"
|
||||
gh pr close "$PR" --repo "$REPO"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
+25
-3
@@ -4,18 +4,40 @@
|
||||
# Cargo.lock IS committed (this workspace produces binaries).
|
||||
|
||||
# --- Blizzard-owned content: NEVER commit ---
|
||||
# A legally-installed 1.12.1 client, read at runtime; not redistributed.
|
||||
# The user's legally-installed 1.12.1 client. Read at runtime; not redistributed.
|
||||
/WoW
|
||||
# Cached / extracted assets derived from an install.
|
||||
# Cached / extracted assets derived from the user's install.
|
||||
/extracted/
|
||||
/out/
|
||||
# Real client files used as test fixtures (document expected paths, do not commit).
|
||||
**/tests/fixtures/
|
||||
|
||||
# Profiling output (`--features bevy/trace_chrome`).
|
||||
# --- benilla's own local state ---
|
||||
# The one folder benilla persists into: config.toml, macros/, bindings/, saved-variables.lua,
|
||||
# camera/. A dev build resolves it to the main checkout (so linked worktrees share one); a
|
||||
# release build puts it beside the binary — which is why it is not called `benilla/`: that name
|
||||
# collides with the binary of the same name. Machine-local by definition — `crate::local_state`
|
||||
# is the only thing in the tree that may write it.
|
||||
/benilla-config
|
||||
|
||||
# The account a scripted run from this checkout logs in as (scripts/probe-identity.sh and the
|
||||
# client's account guard read it): WOW_USER=, WOW_PASS=, WOW_CHAR=, one per line. A credential.
|
||||
/.probe-identity
|
||||
|
||||
# Profiling output: `--features bevy/trace_chrome` drops a multi-GB flame chart in the cwd, which
|
||||
# otherwise leaves the checkout "dirty".
|
||||
trace-*.json
|
||||
|
||||
# OS / editor cruft
|
||||
.DS_Store
|
||||
*.swp
|
||||
/.idea/
|
||||
# Claude Code: settings and skills under .claude/ are each person's own.
|
||||
/.claude/
|
||||
|
||||
# Python bytecode from the `scripts/` tooling.
|
||||
__pycache__/
|
||||
|
||||
# The vanilla addon corpus the UI engine's real-addon tests read (third-party content): a symlink
|
||||
# beside `WoW`, or `BENILLA_ADDON_CORPUS`.
|
||||
/wow-addons-vanilla
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# benilla
|
||||
|
||||
A from-scratch World of Warcraft 1.12.1 client in Rust and Bevy. The reference client is the
|
||||
spec: benilla is a faithful, modern implementation of it, not a place to get creative.
|
||||
|
||||
Read, in this order:
|
||||
|
||||
1. `docs/METHOD.md` — the rules and how we work. Binding for every session.
|
||||
2. `docs/MAP.md` — what is built right now, generated from the code. Orient here before
|
||||
touching anything.
|
||||
3. `docs/CONTRIBUTING.md` — what gets in and how a change is judged.
|
||||
|
||||
The rest of the root is code and data: `crates/` the workspace, `reference/` the 1.12 name
|
||||
catalogues the tests check against, `scripts/` the gates and instruments, `third_party/`
|
||||
vendored code.
|
||||
|
||||
@docs/METHOD.md
|
||||
Generated
-2
@@ -513,7 +513,6 @@ dependencies = [
|
||||
"benilla-formats",
|
||||
"benilla-protocol",
|
||||
"benilla-ui",
|
||||
"benilla-wmo",
|
||||
"benilla-world",
|
||||
"bevy",
|
||||
"bevy_egui",
|
||||
@@ -674,7 +673,6 @@ dependencies = [
|
||||
"bevy",
|
||||
"bytemuck",
|
||||
"image",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
||||
+24
-83
@@ -8,33 +8,18 @@ edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["sam <samuv.eth@gmail.com>"]
|
||||
|
||||
# Shared pins live here; promote a dep the moment two crates pin the same version (the policy
|
||||
# since day one, enacted at the 0530 debt sweep). Per-crate feature additions stay in the crate.
|
||||
# `image` deliberately stays per-crate: its default-features/feature sets differ per consumer,
|
||||
# and a workspace default-features choice can't be subtracted from downstream.
|
||||
# **Lint policy** (decision 2266 §C4). `too_many_arguments` is a readability lint for plain
|
||||
# functions; on a Bevy system the parameter list IS the scheduler's dependency declaration, and
|
||||
# a system that reads eight resources has eight parameters by construction. The lint had fired
|
||||
# 364 times in this workspace and been allowed 364 times, 281 of them on systems — a lint whose
|
||||
# every verdict is overridden is not a gate, it is a tax on adding a system. Allowed once here;
|
||||
# the per-site attributes are gone. `type_complexity` stays on: it fires on shapes a type alias
|
||||
# or a `SystemParam` bundle genuinely fixes.
|
||||
# A version two crates share is pinned in `[workspace.dependencies]`; feature additions stay per
|
||||
# crate. `image` stays per-crate: its consumers differ on default features, and a member cannot
|
||||
# turn off default features that a workspace entry enables.
|
||||
# `too_many_arguments` is off: a Bevy system's parameter list is its dependency declaration.
|
||||
[workspace.lints.clippy]
|
||||
too_many_arguments = "allow"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.102"
|
||||
# bevy's `default` is `["2d", "3d", "ui"]`, and every one of those three pulls `audio`
|
||||
# (= `bevy_audio` + `vorbis`) — so a default bevy compiles rodio, a second cpal, a second ALSA
|
||||
# binding and an ogg/vorbis decoder into a client that mixes through kira and has had bevy's
|
||||
# `AudioPlugin` disabled at boot since 0530. This spells the same `default` out with `audio`
|
||||
# taken away: `2d`/`3d`/`ui` decomposed into the parts they are made of, which is the whole
|
||||
# union of the three minus that one entry (decision 1932 has the expansion, verified against a
|
||||
# before/after feature tree). Nothing else in the graph asks for it — avian3d, bevy_egui and
|
||||
# bevy_transform_interpolation all take bevy with `default-features = false`.
|
||||
#
|
||||
# If a future bevy needs another member, add it here by name; do not reach back for `2d`/`3d`/
|
||||
# `ui`, which would quietly bring the audio stack with it.
|
||||
# bevy's default features spelled out without `audio`: `2d`, `3d` and `ui` each pull `bevy_audio`
|
||||
# and `vorbis`, and the client mixes through kira. Add a feature by name, never `2d`/`3d`/`ui`; a
|
||||
# plugin that takes bevy with its default features brings the audio stack back too.
|
||||
bevy = { version = "0.18.1", default-features = false, features = [
|
||||
"default_app",
|
||||
"default_platform",
|
||||
@@ -45,88 +30,44 @@ bevy = { version = "0.18.1", default-features = false, features = [
|
||||
"scene",
|
||||
"picking",
|
||||
] }
|
||||
# The render app's raw GPU handles (the WOW_GPU_MS meter's query set): bevy 0.18 does not wrap
|
||||
# wgpu's QuerySet family, so the meter reaches for the same wgpu the workspace already builds.
|
||||
# For the `WOW_GPU_MS` meter's query set, which bevy 0.18 does not wrap; must match bevy's wgpu.
|
||||
wgpu = "27"
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
sha1 = "0.10.6"
|
||||
|
||||
# The WoW format + protocol stack is fully in-repo (decision 0021), so there are no warcraft-rs /
|
||||
# wow_messages crates (or forks of them) anywhere in the tree. That is still true and is why this
|
||||
# section stood empty for the project's whole life.
|
||||
#
|
||||
# The one entry below is not a fork of a WoW crate at all. `lua-src` compiles the Lua the client's
|
||||
# addon VM runs on, and benilla's Lua has to accept EXACTLY the grammar the 1.12.1 client's Lua
|
||||
# accepts — no more and no less. Six hunks, all byte-read out of the client's own parser and VM:
|
||||
# three restore what 5.1 removed (the iterator-less generic-for `for k, v in someTable do`, which
|
||||
# 183 of 218 corpus addons are reached by; nesting long strings; the constructor's compat
|
||||
# semicolon), three delete what 5.1 added (`...` as an expression, `#`, `%`). Both directions are
|
||||
# below the reach of any layer above the VM — an opcode and a grammar are not a library function,
|
||||
# a global, or a metamethod — and the corpus ASKS about the second direction directly: 170
|
||||
# `loadstring` sites compile a vararg expression purely to find out which interpreter they are on.
|
||||
#
|
||||
# The fork landed BYTE-IDENTICAL to upstream's 5.1.5 sources on purpose: the pipeline was proven
|
||||
# transparent before any behavioural payload went into it, so when the suite moved, the patch was
|
||||
# the only thing that could have moved it. `third_party/lua-src/BENILLA.md` has the current state,
|
||||
# the per-hunk byte evidence and the verifying command (decisions 1215, 1315, 2101).
|
||||
# lua-src builds the addon VM's Lua 5.1.5, patched to accept exactly the 1.12 client's grammar;
|
||||
# the hunks and the command that checks them: `third_party/lua-src/BENILLA.md`.
|
||||
[patch.crates-io]
|
||||
lua-src = { path = "third_party/lua-src" }
|
||||
# kira 0.12.1 with one patch: spatial gains and the volume tween evaluated per CHUNK and
|
||||
# interpolated, not per frame (decision 1945; `third_party/kira/BENILLA.md`).
|
||||
# kira 0.12.1, patched to compute spatial gains and the volume tween per output chunk and
|
||||
# interpolate them, not per sample frame (`third_party/kira/BENILLA.md`).
|
||||
kira = { path = "third_party/kira" }
|
||||
|
||||
# Optimize *dependencies* even in dev builds. avian/parry collision math (and wgpu/bevy render) is
|
||||
# unusably slow at opt-level 0 — a plain `cargo run` crawls to <1 fps once physics is doing real work.
|
||||
# opt-level 3 on deps (the standard Bevy fix) makes debug builds playable while our own crates stay
|
||||
# lightly optimized for fast incremental rebuilds. One-time cost: deps recompile optimized the first time.
|
||||
# Dependencies at opt-level 3 in dev: at opt-level 0, avian/parry collision drops a run to <1 fps.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
|
||||
# Our own crates at opt-level 1, not 0: Bevy's generic hot paths (material specialization scans,
|
||||
# query par_for_each closures) monomorphize INTO our crate, so at opt 0 they run at debug codegen
|
||||
# speed over every resident entity — measured at Stormwind as the difference between ~45 fps and a
|
||||
# pinned 60 (decision 0362). Warm incremental rebuild cost measured at ~zero. NB: FPS probe numbers
|
||||
# in decisions ≤ 0361 were taken at opt 0 and are not comparable with post-0362 probes.
|
||||
# Our crates at opt-level 1: Bevy's generic hot paths monomorphize into them, and at opt-level 0
|
||||
# Stormwind drops from a pinned 60 fps to ~45; the warm incremental rebuild cost is ~0.
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
# No debuginfo trimming: `debug = "line-tables-only"` was A/B-measured (decision 0192) at zero
|
||||
# recurring win on this stack (macOS unpacked split-debuginfo; sccache; fast ld) — not worth
|
||||
# invalidating every warm target and the sccache cache for.
|
||||
# No debuginfo trimming: `debug = "line-tables-only"` measures no recurring win on macOS + sccache.
|
||||
|
||||
# Shipping-grade optimization, A/B-graded at the pin (decision 0736): −1.0 cpu_ms (−5 %) vs
|
||||
# default release, flat at the empty-map and bare-Bevy floors. The campaign keeps grading on
|
||||
# default `release` — same-day A/B is the law, and fat LTO's link time would tax every
|
||||
# iteration for a constant offset. Build with `--profile ship` for actual releases (add
|
||||
# `RUSTFLAGS=-Ctarget-cpu=native` for a machine-local binary; not set here — it isn't portable).
|
||||
# The shipping build (`--profile ship`): fat LTO measures -1.0 cpu_ms (-5 %) against `release`,
|
||||
# which stays the measured profile because fat LTO's link time would tax every iteration.
|
||||
# `-Ctarget-cpu=native` is not set: it is not portable.
|
||||
[profile.ship]
|
||||
inherits = "release"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
|
||||
# The director's play loop (decision 1157): `release` plus incremental compilation. `benilla-app` is one crate of
|
||||
# ~243k lines, and release is non-incremental by cargo's default, so a one-line edit re-optimizes
|
||||
# all of it — measured 2m47s (43s frontend, ~121s LLVM at opt-level 3) against a 1.8s stamp rebuild
|
||||
# and a 2.5s no-op. Incremental caches both halves per codegen unit, so an edit pays for what it
|
||||
# touched. Cargo applies `incremental` to workspace members ONLY (verified: `target/debug/incremental`
|
||||
# holds our 16 crates and zero registry deps), so the ~760 dependencies stay non-incremental and
|
||||
# sccache-cacheable — 0192's wrapper and the cold-slot build are untouched.
|
||||
#
|
||||
# Why a separate profile rather than flipping `release`: incremental raises the codegen-unit count
|
||||
# and drops cross-unit inlining, which is a real codegen difference. The FPS campaign grades on
|
||||
# default `release` (0736) and same-day A/B is the law, so the graded profile must not quietly
|
||||
# change under it. Run it as `cargo play` (the alias in `.cargo/config.toml`).
|
||||
# `release` plus incremental, run as `cargo play`: under `release` a one-line edit to
|
||||
# `benilla-app` costs 2m47s. Incremental applies to workspace members only, so dependencies stay
|
||||
# sccache-cacheable; it changes codegen, so it lives here and not in the measured `release`.
|
||||
[profile.play]
|
||||
inherits = "release"
|
||||
incremental = true
|
||||
# ThinLTO OFF, and it is not a performance choice -- it is the fix for rustc #86049. `release`
|
||||
# implies thin-LOCAL LTO (ThinLTO across a crate's own codegen units), which promotes each CGU's
|
||||
# internal anonymous constants to external symbols named `anon.<hash>.N.llvm.<moduleid>`. Under
|
||||
# `incremental` some CGUs come back from the cache carrying names minted in an earlier compile,
|
||||
# so a promoted reference finds no definition and `cc` fails with "symbol(s) not found for
|
||||
# architecture arm64" -- an intermittent link failure with no source-level cause, cleared only by
|
||||
# a full rebuild. Measured on the 2026-08-29 occurrence: 7 poisoned CGUs in `benilla-formats`, 48
|
||||
# unresolved symbols. `lto = "off"` disables the promotion step entirely (verified: the play rlib
|
||||
# goes from 381 undefined `anon.*.llvm.*` symbols to 0). `release` and `ship` are UNTOUCHED -- they
|
||||
# are non-incremental, cannot hit this, and stay the graded profiles (0736). Decision 1698.
|
||||
# Not for speed: under `incremental`, thin-local LTO leaves promoted `anon.*.llvm.*` symbols
|
||||
# unresolved and the link fails intermittently (rustc #86049).
|
||||
lto = "off"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
A dynamic light and shadow system for [benilla](https://github.com/samwhosung/benilla), the Rust +
|
||||
Bevy reimplementation of the 1.12.1 client. This branch is upstream benilla (merged at
|
||||
`edec5b22`) plus the lighting work, and nothing else. It needs no data changes: everything is
|
||||
`fd386e75`) plus the lighting work, the enhanced water module and volumetric fog, and nothing else. It needs no data changes: everything is
|
||||
derived from what a 1.12 install already carries (WMO `MOLT` lights, `MOCV` vertex colour, portals,
|
||||
M2 particle emitters, `Light.dbc`).
|
||||
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Issues and pull requests are closed here.** benilla is a solo project developed in a private
|
||||
> tree; this repo is its export, published as squashed snapshots, so a PR here has nothing to land
|
||||
> on. The best way to contribute is to join the [Discord](https://discord.gg/wJSJx467G4) and report
|
||||
> the bugs you find. Questions and ideas are welcome in the same place.
|
||||
|
||||
benilla speaks the original 1.12.1 protocol, so it connects to any server the real client could,
|
||||
and reads its game data at runtime from your own 1.12.1 install. Every file format and the network
|
||||
protocol are implemented from scratch, with no original client code, no third-party WoW crates,
|
||||
@@ -34,12 +28,12 @@ and no bundled game assets.
|
||||
modes from slow fall to roots, a follow camera with collision, boats, zeppelins and taxi flights.
|
||||
- **Networking:** SRP6 auth through world-session crypto, the object mirror into the ECS, and live
|
||||
wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank,
|
||||
loot, the auction house and PvP honor.
|
||||
- **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and
|
||||
character screens through the full HUD, the classic windows (guild, macros and key bindings
|
||||
included), chat, nameplates, floating combat text and tooltips; third-party addons load from
|
||||
a `benilla-config/AddOns/` folder beside the executable (partial: AtlasLoot and Bagnon run,
|
||||
see below).
|
||||
loot, the auction house, PvP honor and battlegrounds.
|
||||
- **UI:** a from-scratch FrameXML + Lua engine that runs the client's own stock interface off
|
||||
your install's patch chain, from the login and character screens through the full HUD, the
|
||||
classic windows (guild, macros and key bindings included), chat, nameplates, floating combat
|
||||
text and tooltips; third-party addons load from a `benilla-config/AddOns/` folder beside the
|
||||
executable (partial: AtlasLoot and Bagnon run).
|
||||
- **Combat:** melee on the faithful swing law, ranged and Auto Shot, casting with GCD and
|
||||
cooldowns, combo points, crowd control that really holds you, and the spell visual pipeline.
|
||||
- **Audio:** music, ambience and SFX under the client's own selection and crossfade rules, with
|
||||
@@ -51,10 +45,8 @@ benilla is done when a 1.12.1 player can do everything here that they could in t
|
||||
client, it looks and feels the same, and it runs from a download on Windows, Linux and macOS.
|
||||
No dates; the order is what is likely, not a promise.
|
||||
|
||||
- Battlegrounds, then the long tail of small features that separates a working client from a
|
||||
finished one.
|
||||
- The long tail of small features that separates a working client from a finished one.
|
||||
- Addons, options and performance, ongoing.
|
||||
- The no-brainer fixes from VanillaFixes, SuperWoW and the like.
|
||||
- Playable downloads for Windows, Linux and macOS. Linux first.
|
||||
|
||||
Not planned: other expansions or client versions, Warden (anticheat).
|
||||
@@ -62,8 +54,10 @@ Not planned: other expansions or client versions, Warden (anticheat).
|
||||
## Running it
|
||||
|
||||
You need a **1.12.1 (build 5875) client install** for game data, a vanilla server to connect to,
|
||||
and stable Rust. Any 1.12.1 core works; [vmangos](https://github.com/vmangos/core) is what
|
||||
development runs against, and cMaNGOS and the rest speak the same protocol.
|
||||
stable Rust and a C compiler (the Lua is built from source; on macOS the Xcode command line
|
||||
tools, on Linux the ALSA and udev development packages). Any 1.12.1 core works;
|
||||
[vmangos](https://github.com/vmangos/core) is what development runs against, and cMaNGOS and
|
||||
the rest speak the same protocol.
|
||||
|
||||
```sh
|
||||
WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla
|
||||
@@ -74,6 +68,12 @@ at any IP or hostname, appending the auth port if yours is remapped
|
||||
(`WOW_HOST=play.example.com:5000`). Credentials go in at the login screen, or set `WOW_USER` /
|
||||
`WOW_PASS` to skip it.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are open. [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) says what gets
|
||||
in and how a change is judged. Bugs, questions and ideas are welcome on the
|
||||
[Discord](https://discord.gg/wJSJx467G4) too.
|
||||
|
||||
---
|
||||
|
||||
Early inspiration and file format guidance came from the
|
||||
@@ -82,9 +82,11 @@ Early inspiration and file format guidance came from the
|
||||
|
||||
benilla is an independent fan project, not affiliated with or endorsed by Blizzard Entertainment.
|
||||
It ships **no Blizzard content** — no art, models, sounds, maps, MPQ contents or FrameXML; you
|
||||
provide your own legally obtained 1.12.1 client. The interface code under
|
||||
`crates/benilla-app/assets/ui/` is ours, written to the client's own layout and API names so that
|
||||
the windows look right and 1.12.1 addons find the names they expect.
|
||||
provide your own legally obtained 1.12.1 client. The stock interface runs off your own install's
|
||||
FrameXML at runtime; the handful of files under `crates/benilla-app/assets/ui/` are our own
|
||||
adapters and developer frames, not copies of it.
|
||||
|
||||
World of Warcraft is a trademark of Blizzard Entertainment, Inc. Our own code is licensed under
|
||||
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option.
|
||||
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. The two vendored components
|
||||
under `third_party/` — the kira audio engine, and a Lua 5.1 patched to the 1.12 client's dialect —
|
||||
keep their own upstream licenses, alongside each.
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "benilla-adt"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "ADT terrain-tile reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021"
|
||||
description = "ADT terrain-tile reader for WoW 1.12.1 (build 5875)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
//! MCAL → one 64×64 RGBA alpha map (R/G/B = the opacity of texture layers 1/2/3; A = 255). Ported
|
||||
//! from wow-adt's `CombinedAlphaMap` so the output is byte-identical: layers are ingested in MCLY
|
||||
//! order (skipping the opaque base), each decoded into the next channel; encodings are 4-bit packed
|
||||
//! (×16), 8-bit raw, or Blizzard RLE (token bit 7 = fill, else copy; low 7 bits = count). `fix_alpha`
|
||||
//! reconstructs a 64×64 plane from 63×63 source by duplicating the previous pixel at the last row/col.
|
||||
//! MCAL to one 64×64 RGBA alpha map, ported from wow-adt's `CombinedAlphaMap`: R, G, B are the
|
||||
//! opacity of texture layers 1, 2, 3 and A is 255. Layers go in MCLY order after the opaque base. A
|
||||
//! layer is 4-bit packed (×17: the reference packs it into an RGBA4444 texel, read as `n / 15`),
|
||||
//! 8-bit raw, or RLE (token bit 7 fill, else copy; the low 7 bits are the count).
|
||||
|
||||
use crate::McnkChunk;
|
||||
|
||||
/// Assembles the per-layer MCAL alpha maps into one 64×64 RGBA buffer (`y`-major, then `x`, then
|
||||
/// channel R,G,B,A).
|
||||
/// The combined 64×64 RGBA alpha map, `y`-major.
|
||||
pub struct CombinedAlphaMap {
|
||||
map: Vec<u8>, // 64*64*4, [y][x][rgba]
|
||||
x: usize,
|
||||
@@ -20,12 +18,13 @@ pub struct CombinedAlphaMap {
|
||||
const W: usize = 64;
|
||||
|
||||
impl CombinedAlphaMap {
|
||||
/// Construct and ingest `chunk`'s alpha layers. `has_big_alpha`/`fix_alpha` select the uncompressed
|
||||
/// encoding width and the 63→64 edge fix (vanilla: `false`, `true`).
|
||||
/// Decode `chunk`'s alpha layers. `has_big_alpha` makes uncompressed layers 8-bit, and
|
||||
/// `fix_alpha` fills the last row and column from their neighbours (a 63×63 source); vanilla
|
||||
/// is `false`, `true`.
|
||||
pub fn new(chunk: &McnkChunk, has_big_alpha: bool, fix_alpha: bool) -> Self {
|
||||
let mut map = vec![0u8; W * W * 4];
|
||||
for px in map.as_chunks_mut::<4>().0 {
|
||||
px[3] = 255; // A unused, set opaque for tool visibility (matches wow-adt)
|
||||
px[3] = 255; // A is unused; opaque, so a dump shows the channels
|
||||
}
|
||||
let mut s = Self {
|
||||
map,
|
||||
@@ -76,10 +75,10 @@ impl CombinedAlphaMap {
|
||||
const PACKED: usize = W * W / 2;
|
||||
if offset + PACKED <= raw.len() {
|
||||
for &p in &raw[offset..offset + PACKED] {
|
||||
if !self.set_next((p & 0x0F) * 16) {
|
||||
if !self.set_next((p & 0x0F) * 17) {
|
||||
break;
|
||||
}
|
||||
if !self.set_next(((p >> 4) & 0x0F) * 16) {
|
||||
if !self.set_next(((p >> 4) & 0x0F) * 17) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -171,3 +170,33 @@ impl CombinedAlphaMap {
|
||||
self.y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn blank() -> CombinedAlphaMap {
|
||||
CombinedAlphaMap {
|
||||
map: vec![0; W * W * 4],
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 0,
|
||||
has_big_alpha: false,
|
||||
fix_alpha: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference packs a 4-bit weight into an RGBA4444 texel (64×64 `0x6b03d0`, 32×32
|
||||
/// `0x6b08d0`), read as `n / 15`, so nibble 15 is full coverage.
|
||||
#[test]
|
||||
fn four_bit_alpha_reads_as_n_over_15() {
|
||||
let mut m = blank();
|
||||
// Low nibble first: texel 0 = 0xF, texel 1 = 0x8.
|
||||
m.ingest_small(&[0x8F; W * W / 2], 0);
|
||||
assert_eq!(m.get(0, 0, 0), 255, "nibble 15 is full coverage");
|
||||
assert_eq!(m.get(1, 0, 0), 136, "nibble 8 is 8/15");
|
||||
let mut z = blank();
|
||||
z.ingest_small(&[0x00; W * W / 2], 0);
|
||||
assert_eq!(z.get(0, 0, 0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
+64
-136
@@ -1,27 +1,11 @@
|
||||
//! An ADT terrain-tile reader for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-adt`
|
||||
//! (decision 0021).
|
||||
//!
|
||||
//! Vanilla ADTs are monolithic, chunked (`IFF`-style: a 4-char magic — stored **reversed** on disk —
|
||||
//! plus a `u32` size, then payload). The root file holds `MTEX` (textures), `MMDX`/`MMID` (M2 paths),
|
||||
//! `MWMO`/`MWID` (WMO paths), `MDDF`/`MODF` (placements), and 256 `MCNK` terrain chunks. Each `MCNK` is
|
||||
//! a 128-byte header followed by sub-chunks (`MCVT` heights, `MCNR` normals, `MCLY` layers, `MCAL`
|
||||
//! alpha, `MCSH` shadow, `MCLQ` liquid), which we locate by **scanning** the chunk payload for their
|
||||
//! magics (robust, and identical to the header-offset data for vanilla).
|
||||
//!
|
||||
//! The shape mirrors what the renderer's terrain/liquid mesher consumes; quirks the consumer
|
||||
//! compensates for (the MCNR `[x,z,y]` byte order, the split predominant-texture/noEffectDoodad
|
||||
//! grids) are preserved deliberately. Proven against `wow-adt` over real ADTs during the decision-0021
|
||||
//! migration (oracle test in git history); the `benilla-formats` terrain golden tests pin the meshing
|
||||
//! end-to-end on every run.
|
||||
//!
|
||||
//! Byte access goes through `benilla-bytes` (decision 0064): every read is bounds-checked and a
|
||||
//! truncated chunk/record is a typed [`Error::Truncated`], never a panic.
|
||||
//! A reader for 1.12.1's monolithic root ADTs: IFF chunks (a reversed magic, a `u32` size, the
|
||||
//! payload) holding the asset names, the placements and 256 `MCNK` terrain chunks.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use benilla_bytes::{chunks, ByteExt};
|
||||
|
||||
/// A terrain vertex normal — stored on disk as `x, z, y` signed bytes.
|
||||
/// A terrain vertex normal: three signed bytes, named `x, z, y` in disk order.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct VertexNormal {
|
||||
pub x: i8,
|
||||
@@ -30,8 +14,7 @@ pub struct VertexNormal {
|
||||
}
|
||||
|
||||
impl VertexNormal {
|
||||
/// `[x/127, y/127, z/127]` — i.e. on-disk `[b0, b2, b1]` / 127 (kept bit-identical to wow-adt; the
|
||||
/// consumer cancels the z/y swap).
|
||||
/// `[x, y, z] / 127`, which is disk bytes `[b0, b2, b1]`; the terrain mesher swaps them back.
|
||||
pub fn to_normalized(&self) -> [f32; 3] {
|
||||
[
|
||||
f32::from(self.x) / 127.0,
|
||||
@@ -41,19 +24,19 @@ impl VertexNormal {
|
||||
}
|
||||
}
|
||||
|
||||
/// MCVT — 145 heights (9×9 outer + 8×8 inner, stride-17 interleave).
|
||||
/// MCVT: 145 heights (9×9 outer + 8×8 inner, stride-17 interleave).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McvtChunk {
|
||||
pub heights: Vec<f32>,
|
||||
}
|
||||
|
||||
/// MCNR — one normal per MCVT vertex.
|
||||
/// MCNR: one normal per MCVT vertex.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McnrChunk {
|
||||
pub normals: Vec<VertexNormal>,
|
||||
}
|
||||
|
||||
/// MCLY per-layer flags (we only need the alpha-compression bit).
|
||||
/// MCLY per-layer flags.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MclyFlags {
|
||||
pub value: u32,
|
||||
@@ -66,7 +49,7 @@ impl MclyFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/// MCLY — one texture layer (16 bytes on disk).
|
||||
/// MCLY: one texture layer (16 bytes on disk).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MclyLayer {
|
||||
pub texture_id: u32,
|
||||
@@ -75,19 +58,19 @@ pub struct MclyLayer {
|
||||
pub effect_id: u32,
|
||||
}
|
||||
|
||||
/// MCLY — the chunk's texture layers (0–4; layer 0 is the opaque base).
|
||||
/// MCLY: the chunk's texture layers (0–4; layer 0 is the opaque base).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MclyChunk {
|
||||
pub layers: Vec<MclyLayer>,
|
||||
}
|
||||
|
||||
/// MCAL — raw alpha-map bytes (indexed per layer via [`MclyLayer::offset_in_mcal`]).
|
||||
/// MCAL: raw alpha-map bytes (indexed per layer via [`MclyLayer::offset_in_mcal`]).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McalChunk {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// MCSH — a 64×64 1-bit baked shadow map (512 bytes).
|
||||
/// MCSH: a 64×64 1-bit baked shadow map (512 bytes).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McshChunk {
|
||||
pub shadow_map: Vec<u8>,
|
||||
@@ -104,11 +87,8 @@ impl McshChunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// One MCLQ liquid vertex (8 bytes: 4 union bytes + an absolute height).
|
||||
///
|
||||
/// The leading 4 bytes are a **union whose meaning is the block's liquid type** — water/ocean spend
|
||||
/// them on a depth byte + flow/foam, magma/slime on a `u16` texture-coordinate pair. Both readers are
|
||||
/// below; which one applies is the caller's decision, from the cell nibble.
|
||||
/// One MCLQ liquid vertex, 8 bytes: 4 union bytes and an absolute height. The union follows the
|
||||
/// cell's liquid type: a depth byte and flow for water and ocean, a UV pair for magma and slime.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LiquidVertex {
|
||||
pub union_data: [u8; 4],
|
||||
@@ -116,19 +96,14 @@ pub struct LiquidVertex {
|
||||
}
|
||||
|
||||
impl LiquidVertex {
|
||||
/// The depth byte (union byte 0) — drives the water depth-swatch on the render side. Water and
|
||||
/// ocean blocks only; on a magma block these bytes are [`Self::texcoords`] instead.
|
||||
/// The water or ocean depth byte (union byte 0).
|
||||
pub fn depth_byte(&self) -> u8 {
|
||||
self.union_data[0]
|
||||
}
|
||||
|
||||
/// The authored `(s, t)` texture-coordinate pair — two little-endian `u16`s over the same 4
|
||||
/// union bytes. **Magma blocks only**: the reference's lava vert-fill is single-stage and reads
|
||||
/// its `tc0` straight from here, where water/ocean instead write a UV into the depth-ramp
|
||||
/// texture (VERIFIED bit-exact `WoW.exe 0x68d890` `liquid_render_verts`, emulation-diffed in
|
||||
/// wow-re `crates/terrain`: `u = (s as i32 as f64 · 3/256) as f32`, same for `t`). The field is
|
||||
/// authored world-continuous — a chunk's east edge repeats its neighbour's west edge exactly —
|
||||
/// so lava tiles seamlessly across MCNK borders.
|
||||
/// A magma block's authored `(s, t)`, two little-endian `u16`s the reference's lava fill takes
|
||||
/// as its UV (`0x68d890`: `u = (s as i32 as f64 · 3/256) as f32`, same for `t`). The field is
|
||||
/// world-continuous, so lava tiles seamlessly across MCNK borders.
|
||||
pub fn texcoords(&self) -> [u16; 2] {
|
||||
[
|
||||
u16::from_le_bytes([self.union_data[0], self.union_data[1]]),
|
||||
@@ -137,51 +112,33 @@ impl LiquidVertex {
|
||||
}
|
||||
}
|
||||
|
||||
/// One MCLQ liquid **block** — a 9×9 absolute-height grid + an 8×8 cell-flag grid, `0x324` bytes.
|
||||
///
|
||||
/// An MCNK carries **one block per set liquid header bit** (bits 2–5), packed back to back and with
|
||||
/// absent bits consuming nothing (VERIFIED wow-re `system/terrain/scratch/adt-format.md`, the cursor
|
||||
/// walk at `0x6af7a3`–`0x6af7cb`). Most liquid chunks set exactly one bit; **28 shipped Azeroth
|
||||
/// chunks set two** — a river *and* the sea, at a river mouth — and carry two blocks at different
|
||||
/// heights (measured: `sizeMCLQ` 1616 = 8 + 2·`0x324`, block 0 the stream at z≈5.0, block 1 the
|
||||
/// ocean at z=0). Reading only the first is how the sea goes missing there.
|
||||
///
|
||||
/// **No liquid type here on purpose.** The header-bit→type ordering (bit2 river / bit3 ocean /
|
||||
/// bit4 magma / bit5 slime) is *INFERRED* upstream — never byte-proven — while the per-cell flag low
|
||||
/// nibble IS the type, VERIFIED at both consumers (`0x6ba970` `and al,0xf`, `0x68d9b0`). So this
|
||||
/// reader takes only the block *count* from the flags and leaves the type to whoever reads
|
||||
/// [`Self::tile_flags`]. (The old `LiquidType::from_mcnk_flags` priority guess labelled all 28
|
||||
/// two-bit chunks "ocean" while handing back the river block's bytes.)
|
||||
/// One MCLQ liquid block: a 9×9 absolute-height grid and an 8×8 cell-flag grid. An MCNK carries
|
||||
/// one per set liquid header bit, back to back (`0x6af7a3`–`0x6af7cb`): two at the 28 shipped river
|
||||
/// mouths. The liquid type is each cell's flag nibble (`0x6ba970`, `0x68d9b0`), not a header bit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MclqChunk {
|
||||
pub min_height: f32,
|
||||
pub max_height: f32,
|
||||
pub vertices: Vec<LiquidVertex>,
|
||||
/// Per-cell flags, row-major 8×8. Low nibble = liquid type (`0xf` = dry/hole); `0x80` shared.
|
||||
/// `0x40` ("fishable") is carried but deliberately unread: its only compiled reader, the
|
||||
/// reference's fishable query `0x69b5d0`, is a zero-caller dead island — fishability is the
|
||||
/// server's verdict (`SPELL_FAILED_NOT_FISHABLE` passthru). Decision 1825.
|
||||
/// Per-cell flags, row-major 8×8: the low nibble is the liquid type (`0xf` dry), `0x80`
|
||||
/// shared. `0x40`, fishable, is unread: the reference's only reader (`0x69b5d0`) has no
|
||||
/// caller, and the server decides fishability.
|
||||
pub tile_flags: [u8; 64],
|
||||
}
|
||||
|
||||
/// MCNK header flag **bit 1** — the chunk a mover may not enter (the "impassable" band that walls
|
||||
/// Searing Gorge and the other mountain rims off from their neighbours).
|
||||
///
|
||||
/// The *producer* is VERIFIED per-instruction in wow-re (`system/terrain/scratch/adt-format.md`,
|
||||
/// the MCNK header walk: `hdr+0x00` bit1 → `chunk+0xc |= 0x40`); the *name* is wow-re's INFERRED
|
||||
/// label for that bit. Consuming it is decision 1266 / report B129.
|
||||
/// MCNK header flag bit 1: a chunk a mover may not enter, the bands along mountain rims such as
|
||||
/// Searing Gorge's. The reference's header walk copies it to its chunk flag `0x40` (`0x6af5f0`).
|
||||
pub const MCNK_IMPASSABLE: u32 = 0x2;
|
||||
|
||||
/// The fields of the 128-byte MCNK header the renderer reads. `pred_tex`/`no_effect_doodad`/
|
||||
/// `unknown_8bytes` mirror wow-adt's (mis)split of the predominant-texture + noEffectDoodad region,
|
||||
/// which the consumer reconstructs — kept identical on purpose.
|
||||
/// The MCNK header fields the renderer reads. The names mislead: `pred_tex` and `no_effect_doodad`
|
||||
/// are the two halves of the predominant-texture map at `+0x40`, and `unknown_8bytes` is the real
|
||||
/// noEffectDoodad at `+0x50`; the consumer reassembles them.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McnkHeader {
|
||||
pub flags: u32,
|
||||
pub index_x: u32,
|
||||
pub index_y: u32,
|
||||
/// `AreaTable.dbc` id of this chunk (header +0x34) — the zone/subzone the player is standing
|
||||
/// in; drives zone music/ambience/reverb selection (decision 0070).
|
||||
/// `AreaTable.dbc` id of the chunk's zone or subzone (`+0x34`).
|
||||
pub area_id: u32,
|
||||
pub holes_low_res: u16,
|
||||
pub pred_tex: [u8; 8],
|
||||
@@ -191,9 +148,7 @@ pub struct McnkHeader {
|
||||
}
|
||||
|
||||
impl McnkHeader {
|
||||
/// Is this chunk flagged [`MCNK_IMPASSABLE`]? The flag is a header bit, so its granularity is
|
||||
/// the whole 33.333 yd chunk; in the shipped data the set chunks form contiguous ribbons along
|
||||
/// zone rims (Searing Gorge's is 48 chunks of `Azeroth_33_44` alone).
|
||||
/// Whether the raw header sets [`MCNK_IMPASSABLE`], which covers the whole 33.333 yd chunk.
|
||||
pub fn impassable(&self) -> bool {
|
||||
self.flags & MCNK_IMPASSABLE != 0
|
||||
}
|
||||
@@ -208,12 +163,11 @@ pub struct McnkChunk {
|
||||
pub layers: Option<MclyChunk>,
|
||||
pub alpha: Option<McalChunk>,
|
||||
pub shadow: Option<McshChunk>,
|
||||
/// The chunk's MCLQ liquid blocks — **one per set liquid header bit**, in on-disk order (see
|
||||
/// [`MclqChunk`]). Empty on a dry chunk; two at a river mouth.
|
||||
/// The MCLQ liquid blocks in disk order: none on a dry chunk, two at a river mouth.
|
||||
pub liquids: Vec<MclqChunk>,
|
||||
}
|
||||
|
||||
/// MDDF — a placed M2 doodad (36 bytes).
|
||||
/// MDDF: a placed M2 doodad (36 bytes).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DoodadPlacement {
|
||||
pub name_id: u32,
|
||||
@@ -224,7 +178,7 @@ pub struct DoodadPlacement {
|
||||
pub flags: u16,
|
||||
}
|
||||
|
||||
/// MODF — a placed WMO (64 bytes).
|
||||
/// MODF: a placed WMO (64 bytes).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WmoPlacement {
|
||||
pub name_id: u32,
|
||||
@@ -233,8 +187,8 @@ pub struct WmoPlacement {
|
||||
pub rotation: [f32; 3],
|
||||
pub flags: u16,
|
||||
pub doodad_set: u16,
|
||||
/// `WMOAreaTable.NameSetID` selector — which naming/audio variant of the building this
|
||||
/// placement is (Northshire Abbey vs Tyr's Hand Abbey are one WMO, different name sets).
|
||||
/// `WMOAreaTable.NameSetID`: which name and audio variant of the WMO this placement is
|
||||
/// (Northshire Abbey and Tyr's Hand Abbey are one WMO).
|
||||
pub name_set: u16,
|
||||
}
|
||||
|
||||
@@ -249,8 +203,7 @@ pub struct RootAdt {
|
||||
pub mcnk_chunks: Vec<McnkChunk>,
|
||||
}
|
||||
|
||||
/// Either a vanilla monolithic root ADT (all we produce) — kept as an enum to mirror the consumer's
|
||||
/// `ParsedAdt::Root(..)` match.
|
||||
/// A parsed ADT; vanilla has only the monolithic root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ParsedAdt {
|
||||
Root(Box<RootAdt>),
|
||||
@@ -274,8 +227,7 @@ impl std::error::Error for Error {}
|
||||
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Split a NUL-separated string blob into a `Vec<u32>` of start offsets paired with strings (used to
|
||||
/// resolve MMDX/MWMO via MMID/MWID offsets).
|
||||
/// The NUL-terminated string at `offset`: an MMDX or MWMO name by its MMID or MWID offset.
|
||||
fn cstring_at(blob: &[u8], offset: usize) -> String {
|
||||
let end = blob[offset.min(blob.len())..]
|
||||
.iter()
|
||||
@@ -285,7 +237,7 @@ fn cstring_at(blob: &[u8], offset: usize) -> String {
|
||||
String::from_utf8_lossy(&blob[offset.min(blob.len())..end]).into_owned()
|
||||
}
|
||||
|
||||
/// Parse a vanilla monolithic ADT. (`cursor` position is ignored; the file starts at the first chunk.)
|
||||
/// Parse a vanilla monolithic ADT; the cursor's position is ignored.
|
||||
pub fn parse_adt(cursor: &mut Cursor<&[u8]>) -> Result<ParsedAdt> {
|
||||
let b: &[u8] = cursor.get_ref();
|
||||
|
||||
@@ -312,7 +264,7 @@ pub fn parse_adt(cursor: &mut Cursor<&[u8]>) -> Result<ParsedAdt> {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve model/WMO paths via the offset tables (the order MDDF/MODF `name_id` indexes).
|
||||
// MDDF/MODF `name_id` indexes the MMID/MWID offset tables.
|
||||
let models = mmid
|
||||
.iter()
|
||||
.map(|&o| cstring_at(&mmdx, o as usize))
|
||||
@@ -340,7 +292,6 @@ fn split_strings(blob: &[u8]) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `name` tags the truncation error with the chunk this record list came from (MMID/MWID).
|
||||
fn read_u32_list(data: &[u8], name: &'static str) -> Result<Vec<u32>> {
|
||||
data.as_chunks::<4>()
|
||||
.0
|
||||
@@ -388,18 +339,17 @@ fn read_modf(data: &[u8]) -> Result<Vec<WmoPlacement>> {
|
||||
r.f32_at(24).ok_or(Error::Truncated("MODF"))?,
|
||||
r.f32_at(28).ok_or(Error::Truncated("MODF"))?,
|
||||
],
|
||||
// 0x20..0x38 = bounding box (6 f32); then:
|
||||
// 0x20..0x38: the bounding box (6 f32), unread.
|
||||
flags: r.u16_at(56).ok_or(Error::Truncated("MODF"))?,
|
||||
doodad_set: r.u16_at(58).ok_or(Error::Truncated("MODF"))?,
|
||||
name_set: r.u16_at(60).ok_or(Error::Truncated("MODF"))?,
|
||||
// 0x3E scale u16 — unused.
|
||||
// 0x3E: a u16, unread.
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse one MCNK: a 128-byte header, then sub-chunks located by scanning the payload for their
|
||||
/// (reversed) magics.
|
||||
/// Parse one MCNK: the 128-byte header, then the sub-chunks at the header's offsets.
|
||||
fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
|
||||
if data.len() < 128 {
|
||||
return Err(Error::Truncated("MCNK header"));
|
||||
@@ -443,14 +393,9 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
|
||||
liquids: Vec::new(),
|
||||
};
|
||||
|
||||
// Locate sub-chunks by the MCNK header offsets (the authoritative method — sequential scanning
|
||||
// breaks when sub-chunks aren't contiguous, e.g. AhnQiraj). MCVT/MCNR offsets live in the
|
||||
// `multipurpose_field` (0x14/0x18); the rest are explicit header fields. The offset base differs
|
||||
// across sources (relative to the MCNK magic vs the MCNK data), so we auto-detect per sub-chunk by
|
||||
// validating the (reversed) magic at both candidate positions. Returns the payload `(start, size)`.
|
||||
// `ofs` comes straight off a (possibly corrupt) file's header, so both the "minus 8" candidate
|
||||
// and the 8-byte header read use checked/bounds-checked arithmetic (`checked_sub`/`bytes_at`/
|
||||
// `u32_at`) — a wrapped or out-of-range offset yields `None`, never a panic.
|
||||
// Sub-chunks sit at the header's offsets, not in sequence (AhnQiraj's are not contiguous).
|
||||
// An offset may count from the MCNK magic or from its data, so the reversed magic is checked
|
||||
// at both; yields the payload's `(start, size)`.
|
||||
let locate = |ofs: u32, magic: &[u8; 4]| -> Option<(usize, usize)> {
|
||||
if ofs == 0 {
|
||||
return None;
|
||||
@@ -464,8 +409,7 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
|
||||
}
|
||||
None
|
||||
};
|
||||
// `start` always comes from a `locate` hit (bounds-proven above); `len` is attacker-controlled,
|
||||
// so the end is saturating before clamping to the buffer.
|
||||
// `locate` proves `start` in bounds; `len` is unvalidated, so the end saturates, then clamps.
|
||||
let slice = |start: usize, len: usize| &data[start..start.saturating_add(len).min(data.len())];
|
||||
|
||||
if let Some((p, n)) = locate(
|
||||
@@ -517,8 +461,8 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
|
||||
}
|
||||
chunk.layers = Some(MclyChunk { layers });
|
||||
}
|
||||
// MCAL's data length is the header's `size_alpha`, NOT the sub-chunk's declared size (they differ
|
||||
// on some files, e.g. AhnQiraj — wow-adt reads `size_alpha`).
|
||||
// MCAL's length is the header's `size_alpha`, not the sub-chunk's own size: they differ on some
|
||||
// files (AhnQiraj).
|
||||
if let Some((p, _)) = locate(
|
||||
h.u32_at(0x24).ok_or(Error::Truncated("MCNK header"))?,
|
||||
b"LACM",
|
||||
@@ -540,8 +484,7 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
|
||||
shadow_map[..k].copy_from_slice(&sub[..k]);
|
||||
chunk.shadow = Some(McshChunk { shadow_map });
|
||||
}
|
||||
// MCLQ, like MCAL, takes its length from the MCNK header (`size_liquid`): the MCLQ sub-chunk's
|
||||
// own declared size is always 0.
|
||||
// MCLQ's length is the header's `size_liquid` too: its own declared size is always 0.
|
||||
if let Some((p, _)) = locate(
|
||||
h.u32_at(0x60).ok_or(Error::Truncated("MCNK header"))?,
|
||||
b"QLCM",
|
||||
@@ -555,14 +498,12 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
|
||||
|
||||
/// Bytes per MCLQ block: `{f32 min, f32 max, 81×8B verts, 64B cell flags, u32 nFlow, 2×40B flow}`.
|
||||
const MCLQ_BLOCK: usize = 0x324;
|
||||
/// Liquid header bits (2–5) — their **count** is the number of packed MCLQ blocks. Only the count is
|
||||
/// read: the bit→type ordering is INFERRED, the cell nibble is VERIFIED (see [`MclqChunk`]).
|
||||
/// Liquid header bits 2–5: how many are set is how many MCLQ blocks there are.
|
||||
const MCNK_LIQUID_BITS: u32 = 0x3c;
|
||||
|
||||
/// Read the packed MCLQ blocks — one per set liquid header bit, back to back (VERIFIED wow-re
|
||||
/// `adt-format.md`). Stops early on a block the payload can't cover, so a truncated tail degrades to
|
||||
/// the blocks that *are* whole rather than to nothing. A chunk with an MCLQ sub-chunk but no liquid
|
||||
/// bit set still yields one block, matching the pre-multi-block reader.
|
||||
/// Read the packed MCLQ blocks, stopping at the first one the payload cannot hold. A chunk with an
|
||||
/// MCLQ sub-chunk but no liquid bit set still yields one block; the reference creates one per set
|
||||
/// bit only (`0x6af760`).
|
||||
fn read_mclq_blocks(sub: &[u8], mcnk_flags: u32) -> Vec<MclqChunk> {
|
||||
let want = (mcnk_flags & MCNK_LIQUID_BITS).count_ones().max(1) as usize;
|
||||
let mut out = Vec::with_capacity(want);
|
||||
@@ -578,9 +519,8 @@ fn read_mclq_blocks(sub: &[u8], mcnk_flags: u32) -> Vec<MclqChunk> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `None` (not an error) below the minimum size: too-short MCLQ data is treated as "no liquid",
|
||||
/// matching the pre-0064 reader — a real-data tolerance, not a hardening carve-out. (The minimum is
|
||||
/// the *read* span, short of the full [`MCLQ_BLOCK`]: the trailing flow vectors are unread.)
|
||||
/// Too-short MCLQ data is no liquid, not an error. The minimum stops short of [`MCLQ_BLOCK`]: the
|
||||
/// trailing flow data is unread.
|
||||
fn read_mclq_block(sub: &[u8]) -> Option<MclqChunk> {
|
||||
const NEED: usize = 8 + 81 * 8 + 64;
|
||||
if sub.len() < NEED {
|
||||
@@ -621,9 +561,7 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// A 128-byte MCNK header with a single MCVT (145 heights) sub-chunk placed right after it,
|
||||
/// referenced via the "offset relative to the MCNK magic" convention (multipurpose field @0x14
|
||||
/// pointing at the MCVT chunk's own magic, 128 bytes in).
|
||||
/// An MCNK header and one MCVT after it, the `0x14` offset pointing at MCVT's magic, 128 in.
|
||||
fn mcnk_with_mcvt() -> Vec<u8> {
|
||||
let mut data = vec![0u8; 128];
|
||||
data[0x14..0x18].copy_from_slice(&128u32.to_le_bytes());
|
||||
@@ -679,9 +617,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn partial_trailing_mddf_record_is_dropped_not_a_panic() {
|
||||
// A record shorter than the 36-byte MDDF stride: `chunks_exact` drops the short remainder
|
||||
// (a pre-existing, unchanged leniency — every *complete* 36-byte record is still
|
||||
// bounds-checked via `ByteExt`, so this can never panic or misread).
|
||||
// Shorter than the 36-byte stride: `as_chunks` drops the remainder.
|
||||
let b = chunk(b"FDDM", &[0u8; 10]);
|
||||
let ParsedAdt::Root(root) = parse(&b).expect("parses");
|
||||
assert!(root.doodad_placements.is_empty());
|
||||
@@ -695,9 +631,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn corrupt_mcnk_subchunk_offset_skips_cleanly_never_panics() {
|
||||
// ofs = 3: `checked_sub(8)` on the second candidate is `None`, and the first candidate (3)
|
||||
// doesn't land on a valid magic — must yield `None`, not wrap/panic (the verified
|
||||
// `ofs.wrapping_sub(8)` trap this replaces).
|
||||
// ofs = 3: the minus-8 candidate underflows and 3 holds no magic.
|
||||
let mut data = vec![0u8; 128];
|
||||
data[0x14..0x18].copy_from_slice(&3u32.to_le_bytes());
|
||||
let b = chunk(b"KNCM", &data);
|
||||
@@ -716,20 +650,16 @@ mod tests {
|
||||
fn short_mclq_is_treated_as_no_liquid_not_an_error() {
|
||||
let mut data = vec![0u8; 128];
|
||||
data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes()); // MCLQ offset
|
||||
data[0x64..0x68].copy_from_slice(&4u32.to_le_bytes()); // size_liquid — far short of NEED
|
||||
data[0x64..0x68].copy_from_slice(&4u32.to_le_bytes()); // size_liquid, far short of NEED
|
||||
data.extend(chunk(b"QLCM", &[0u8; 4]));
|
||||
let b = chunk(b"KNCM", &data);
|
||||
let ParsedAdt::Root(root) = parse(&b).expect("parses");
|
||||
assert!(root.mcnk_chunks[0].liquids.is_empty());
|
||||
}
|
||||
|
||||
/// **One MCLQ block per set liquid header bit**, packed back to back — the shape that made the
|
||||
/// sea vanish at 28 shipped river mouths when only the first was read (see [`MclqChunk`]).
|
||||
/// Two bits set ⇒ two blocks, each keeping its OWN cell flags, so the consumer can tell the
|
||||
/// river block from the ocean block by nibble.
|
||||
/// Each block keeps its own cell flags, so the river and the ocean tell apart by nibble.
|
||||
#[test]
|
||||
fn a_two_bit_mcnk_yields_two_mclq_blocks_in_disk_order() {
|
||||
// One block: min/max, 81×8B verts, 64B cell flags, then the flow tail — 0x324 total.
|
||||
let block = |height: f32, nibble: u8| {
|
||||
let mut b = vec![0u8; MCLQ_BLOCK];
|
||||
b[0..4].copy_from_slice(&height.to_le_bytes());
|
||||
@@ -740,11 +670,11 @@ mod tests {
|
||||
b[8 + 81 * 8..8 + 81 * 8 + 64].fill(nibble);
|
||||
b
|
||||
};
|
||||
let mut payload = block(5.0, 4); // bit 2 (river) — first on disk
|
||||
payload.extend(block(0.0, 1)); // bit 3 (ocean) — second
|
||||
let mut payload = block(5.0, 4); // the river, first on disk
|
||||
payload.extend(block(0.0, 1)); // the ocean, second
|
||||
|
||||
let mut data = vec![0u8; 128];
|
||||
data[0x00..0x04].copy_from_slice(&0x0cu32.to_le_bytes()); // liquid bits 2 AND 3
|
||||
data[0x00..0x04].copy_from_slice(&0x0cu32.to_le_bytes()); // liquid bits 2 and 3
|
||||
data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes());
|
||||
data[0x64..0x68].copy_from_slice(&((payload.len() + 8) as u32).to_le_bytes());
|
||||
data.extend(chunk(b"QLCM", &payload));
|
||||
@@ -759,9 +689,7 @@ mod tests {
|
||||
assert_eq!(liquids[1].min_height, 0.0);
|
||||
}
|
||||
|
||||
/// The magma union bytes are an authored `(s, t)` u16 pair, little-endian over the same 4 bytes
|
||||
/// the water blocks spend on a depth byte — VERIFIED `0x68d890` reads them as `u16 → i32`, so
|
||||
/// `0xffff` is 65535 and never −1.
|
||||
/// `0x68d890` reads the magma pair as `u16 → i32`, so `0xffff` is 65535, never −1.
|
||||
#[test]
|
||||
fn magma_union_bytes_read_as_two_unsigned_u16_texcoords() {
|
||||
let v = LiquidVertex {
|
||||
|
||||
+38
-125
@@ -3,164 +3,85 @@ name = "benilla-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "The Bevy app as a library: everything but the build-id stamp, which lives in the `benilla` launcher shim so a commit dirties ~30 lines instead of this crate (decision 0993)"
|
||||
description = "The Bevy app as a library: everything but the build-id stamp, which lives in the `benilla` launcher so a new commit does not rebuild this crate"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# **The dev/player seam** (decisions 0026 + 1173). Default-ON, so every ordinary `cargo run`,
|
||||
# capture, probe and gate builds exactly what it built before. `--no-default-features` compiles
|
||||
# the instruments — the debug panel, the perf HUD, the inspector, the capture harness and the
|
||||
# probe fleet — physically OUT: not dormant behind a runtime flag, absent. That is the whole
|
||||
# requirement a player client has of them, and a runtime toggle cannot meet it.
|
||||
#
|
||||
# The rule the seam enforces is one-directional: **dev may see anything; nothing may depend on
|
||||
# dev.** It is enforced by `cargo build -p benilla --no-default-features` in `scripts/gates.sh`,
|
||||
# not by anyone remembering it — 0026 adopted the rule in June 2026 with no failing build behind
|
||||
# it, and twenty-four couplings had accumulated by August (1173).
|
||||
# `dev` compiles in the instruments: the debug panel, perf HUD, inspector, capture harness and
|
||||
# probes. Nothing may depend on it; `scripts/gates.sh` builds `benilla` with no default features.
|
||||
default = ["dev"]
|
||||
#
|
||||
# The three benilla crates below are taken with `default-features = false` and their `dev`
|
||||
# re-exported here (1175). Their `dev` is default-on too, so without the round trip a
|
||||
# `--no-default-features` build gets the feature back through unification — and the binary
|
||||
# carries the build machine's source-tree path after all. Same trap as the shim's, one level down.
|
||||
# `benilla-assets`, `-formats` and `-world` come in without default features and their `dev` is
|
||||
# forwarded here: it is on by default in each, so unification would put it back in a player build.
|
||||
dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:bevy_egui"]
|
||||
# Opt-in Tracy profiling: `cargo run --features tracy` streams per-system `info_span!` markers (on the
|
||||
# hot systems) + render-pass CPU timings to a Tracy capture (decision 0026 — the dev/player seam;
|
||||
# a `docs/howto/running.md` this once cited never landed).
|
||||
tracy = ["bevy/trace_tracy"]
|
||||
# Opt-in Chrome-trace profiling: `cargo run --features trace_chrome` writes a `trace-<ts>.json` of
|
||||
# every system's per-run span next to the working dir. Tracy's offline twin — it needs no GUI and no
|
||||
# live capture, so a per-system cost question is answerable from a terminal (and from a run someone
|
||||
# else recorded). Aggregate it with `scripts/tracesum.py`.
|
||||
# `--features trace_chrome` writes every system's per-run span to `trace-<ts>.json` in the cwd.
|
||||
trace_chrome = ["bevy/trace_chrome"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
# OS clipboard access for the UI EditBox copy/cut/paste chords (chat input). Already in the tree via
|
||||
# bevy_egui; pinned to the same 3.x so there's one copy. Read on the main thread (the UI key feed is
|
||||
# a NonSend system) — required for the macOS NSPasteboard path. Covers macOS, Windows, and Linux/BSD
|
||||
# under X11; the Wayland backend is `smithay-clipboard` below (decision 0702).
|
||||
# The EditBox clipboard chords, read on the main thread as macOS NSPasteboard requires; Wayland
|
||||
# goes through `smithay-clipboard`. The 3.x bevy_egui uses, so the graph has one copy.
|
||||
arboard = "3"
|
||||
benilla-ui = { path = "../benilla-ui" }
|
||||
bevy.workspace = true
|
||||
wgpu.workspace = true
|
||||
# Optional: the dev overlays are its only consumers (the panel, the perf pill, the inspector
|
||||
# card), so a player build does not link it at all (`dev` feature, decision 1173).
|
||||
bevy_egui = { version = "0.39", optional = true }
|
||||
# Zero-copy POD→bytes for the shared light storage buffer (one per-frame write_buffer). Already in
|
||||
# the tree transitively; pinned to the same version as the dev-dep below.
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
# `f16` decode for the booth-target dump (`WOW_BOOTH_DUMP`): the booth render targets are
|
||||
# `Rgba16Float` (decision 0804) and the PNG wants display bytes. Already in the tree via
|
||||
# wgpu/bevy_image; declared so the call site is an honest dependency.
|
||||
# `f16` decode for the `WOW_BOOTH_DUMP` dump of the `Rgba16Float` booth render targets.
|
||||
half = "2"
|
||||
# The shipped FrameXML tree, compiled into the binary (decision 1175 §2): 61 files, ~2.8 MB, so the
|
||||
# client cannot boot without its interface on a machine that has never seen our source. A proc
|
||||
# macro plus a no-dependency runtime shim — it adds nothing to the graph at runtime, and it is the
|
||||
# crate everyone uses for this. `assets/ui` is OUR content (MIT/Apache), not Blizzard's.
|
||||
# Compiles `assets/ui` into the binary, so the interface loads on a machine without the source.
|
||||
include_dir = "0.7"
|
||||
# The 1.12 chat filters are PCRE over shipped DBCs (decision 2077; `text_filter`): 2289 profanity
|
||||
# expressions and 28 spam ones, compiled once at startup and run over every chat line. `RegexSet` is
|
||||
# exactly the shape that needs — one automaton over the whole list for the "can anything match at
|
||||
# all" pass — and the crate is already in the tree transitively.
|
||||
# The 1.12 chat filters (`text_filter`): the DBCs' 2289 profanity and 28 spam patterns, with one
|
||||
# `RegexSet` for the any-match pass over every chat line.
|
||||
regex = "1"
|
||||
# `getrusage` — whole-process CPU time (all threads), the perf probes' `cpu_ms`/`cpu_pct` metric
|
||||
# (`perf::process_cpu_secs`). Wall-clock fps is unusable as a regression instrument on a machine
|
||||
# running parallel sessions' builds; CPU-per-frame is, and it is also the number the Mac report
|
||||
# is about ("250 % CPU at 59 fps"). Was dev-only; since 1857 the output backend needs it in every
|
||||
# build too (`mach_absolute_time`, the host clock its meters read; `thread_policy_set`, the render
|
||||
# thread's realtime policy).
|
||||
# `getrusage` for the perf probes' process CPU time; `mach_absolute_time` and `thread_policy_set`
|
||||
# for the audio output's meter clock and its render thread's realtime policy.
|
||||
libc = "0.2"
|
||||
# Resample varying-size layer BLPs to a uniform size for the terrain texture array; `png` so we can
|
||||
# encode the cursor image into data an NSImage can load (macOS hardware cursor).
|
||||
# PNG encoding only: the macOS hardware cursor's NSImage data and screenshots.
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# The net↔ECS bridge: a background IO thread streams typed `SessionEvent`s to the ECS over a
|
||||
# channel, and outbound `ClientCommand`s back. crossbeam's Sender/Receiver are `Send + Sync + Clone`
|
||||
# (std's mpsc Sender is `!Sync`, so it can't be a Bevy resource without a mutex — the very thing this
|
||||
# bridge deletes). The canonical Bevy background-thread→channel→system idiom. See decision 0006.
|
||||
# Thread-to-ECS channels such as the net bridge's: crossbeam's `Receiver` is `Sync`, so either end
|
||||
# can be a Bevy resource without a mutex.
|
||||
crossbeam-channel = "0.5"
|
||||
benilla-assets = { path = "../benilla-assets", default-features = false }
|
||||
benilla-formats = { path = "../benilla-formats", default-features = false }
|
||||
benilla-protocol = { path = "../benilla-protocol" }
|
||||
benilla-world = { path = "../benilla-world", default-features = false }
|
||||
# The CVar config diff (`benilla/config.toml`, decision 0954): serde derive on the persisted
|
||||
# shape, toml as its format. (serde_json left with the era manifest — 0978.)
|
||||
# The CVar diff persisted in `benilla-config/config.toml`.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
# ECS-native physics: collider storage + broadphase BVH + shape-casts, with a thin custom kinematic
|
||||
# character controller on top (decision 0009). Pinned 0.6: it targets Bevy 0.18 (verified single,
|
||||
# unified bevy 0.18.1 in the tree); 0.7.0 jumps to Bevy 0.19 and would duplicate bevy. avian wraps
|
||||
# parry under the hood, so adopting it lets us drop the *direct* parry3d dep once the bespoke
|
||||
# CollisionWorld is gone. See docs… (decision 0007 superseded by 0009).
|
||||
# Collision for our kinematic character controller; 0.6 targets Bevy 0.18, 0.7 moves to 0.19.
|
||||
avian3d = "0.6"
|
||||
# The glyph shaper + rasterizer for `FontString` text (decision 0068 §2, probe-verified in
|
||||
# `probes/text-glyph`). Pinned to 0.16 — the exact version already in the tree transitively
|
||||
# (`bevy_text`'s own dependency, see `Cargo.lock`) — so this is the *same* `cosmic-text` the decision
|
||||
# already commits to, not a second copy. Used as a standalone shaper/rasterizer (`FontSystem` +
|
||||
# `Buffer` + `SwashCache`) in `ui_text.rs`, never `bevy_text`'s `Text2d`/ECS integration (that path
|
||||
# doesn't feed our custom z-ordered `UiQuad` stream).
|
||||
# Shapes and rasterizes `FontString` text standalone (`ui_text`), at bevy_text's own 0.16.
|
||||
cosmic-text = "0.16"
|
||||
# The font database `cosmic-text` 0.16 uses (its `Source`/`ID`/`FaceInfo` appear in `ui_text.rs`'s
|
||||
# public-in-crate types). Pinned to the exact version `cosmic-text` 0.16 depends on so there's one
|
||||
# `fontdb` in the graph.
|
||||
# `cosmic-text`'s font database, whose types `ui_text` names; must stay the version it depends on.
|
||||
fontdb = "0.23"
|
||||
# kira renders the mix; the device it plays through is OURS (`sound::output`, decision 1857) —
|
||||
# kira's own `cpal` backend is not enabled on any target. On macOS that took cpal out of the graph
|
||||
# entirely: it could neither report a lost default device nor rebuild on one, and it discarded the
|
||||
# IO-cycle timestamp the deadline meters need. Off macOS cpal is back (1920) but as OUR device
|
||||
# layer under the same backend, not as kira's — the ring, the render thread and the meters are the
|
||||
# same code on every platform.
|
||||
# The mixer. Its `cpal` backend stays off on every target: `sound::output` is our own backend.
|
||||
kira = { version = "0.12", default-features = false, features = ["mp3", "wav", "pcm", "adpcm"] }
|
||||
# The realtime-audio allocation tripwire (decision 1857): `assert_no_alloc(|| …)` around the output
|
||||
# IO callback and the render pass aborts a DEBUG build on any allocation inside them, so a
|
||||
# realtime-safety regression fails a test or a smoke instead of reaching an ear. Release builds
|
||||
# compile it out (`disable_release`, the crate's default) and carry no allocator wrapper.
|
||||
# Aborts a debug build on an allocation in the audio IO callback or render pass; off in release.
|
||||
assert_no_alloc = "1.1"
|
||||
# kira's own decoder, named directly for one trait: the mixer feeds streams through its own
|
||||
# `MediaSource` so kira's per-stream decode thread runs OUR code and can promote its QoS (decision
|
||||
# 1109 — a bare-spawned decode thread starves under the world-entry burst and zero-fills the mix).
|
||||
# No codecs of its own (kira's features enable those); must stay the version kira resolves — one
|
||||
# symphonia in the graph, or the trait impl won't line up.
|
||||
# For `MediaSource`: kira's decode thread reads through our source, which raises the thread's QoS
|
||||
# so the world-entry burst cannot starve it. Must stay kira's version, or the trait won't match.
|
||||
symphonia = { version = "0.5", default-features = false }
|
||||
# The mix tap's audio-thread → writer-thread ring (decision 1112): the tap effect must never
|
||||
# block or allocate on the render path. kira's own SPSC ring crate; kept on the version kira
|
||||
# resolves so the graph carries one rtrb.
|
||||
# The audio threads' lock-free SPSC rings (output, mix tap); kira's version, so the graph has one.
|
||||
rtrb = "0.3"
|
||||
mint = "0.5"
|
||||
# The gossip greeting is a weighted random DRAW per menu-open, not a fixed pick (wow-re
|
||||
# `gossip-npctext-law.md` — the reference re-rolls in its gossip handler), so the app needs a uniform
|
||||
# source. Pinned to 0.8: the version `benilla-srp` already depends on, so this adds no crate.
|
||||
# The gossip greeting's weighted draw, re-rolled per open as in the reference; `benilla-srp`'s 0.8.
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
# The WMO portal-cull audit's raw-face instruments (`WOW_PIN_COL`): the pin probe compares the
|
||||
# filtered walking-collision set against the group file's FULL face list with per-face MOPY flags,
|
||||
# which only the raw parser exposes — benilla-formats' gathers are exactly the filtered views.
|
||||
benilla-wmo = { path = "../benilla-wmo" }
|
||||
|
||||
# Linux/BSD: the Wayland clipboard (decision 0702). We build with bevy's `wayland` feature, so on a
|
||||
# Wayland session winit gives us a native Wayland window and `arboard` — X11-only without its
|
||||
# non-default `wayland-data-control` feature — finds no X11 CLIPBOARD owner and every paste fails.
|
||||
# smithay-clipboard is the right crate for a windowed app: it drives the *standard* `wl_data_device`
|
||||
# off our own `wl_display`/seat, which every compositor supports. arboard's own Wayland feature would
|
||||
# not do: it speaks `wlr-data-control`, a privileged manager protocol GNOME/Mutter deliberately
|
||||
# doesn't implement. `raw-window-handle` (pinned to the 0.6 already in the tree via bevy_window, so
|
||||
# this adds no crate) is only needed to read the `wl_display` out of bevy's `RawHandleWrapper`.
|
||||
# Linux/BSD Wayland clipboard: smithay-clipboard drives the standard `wl_data_device` on our own
|
||||
# `wl_display`, read through raw-window-handle (bevy's 0.6). arboard's Wayland feature speaks
|
||||
# `wlr-data-control`, which GNOME does not implement.
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))'.dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
smithay-clipboard = "0.7"
|
||||
|
||||
# macOS: drive a native NSCursor for a crisp hardware cursor. winit drives the cursor via AppKit
|
||||
# cursor-rects, which our continuous Metal render loop doesn't honor on mouse-move (custom cursor /
|
||||
# hide revert to the arrow); we bypass it with NSCursor + NSWindow::disableCursorRects. See
|
||||
# docs/knowledge/camera-control.md.
|
||||
# macOS: AppKit through objc2 drives a native NSCursor with the window's cursor rects disabled;
|
||||
# winit's cursor rects revert to the arrow under our continuous render loop (`cursor.rs`).
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The audio device layer (decision 1857, `sound::output::coreaudio`): the HAL output unit through
|
||||
# coreaudio-rs's `AudioUnit`, and the AudioObject property/listener API through the objc2 CoreAudio
|
||||
# bindings — the two crates cpal itself stood on, now used directly. The versions are the ones the
|
||||
# graph already carries; the feature sets are the ones cpal proved to compile against.
|
||||
# The audio device layer (`sound::output::coreaudio`): the HAL output unit through coreaudio-rs,
|
||||
# the AudioObject property API through objc2-core-audio, at the versions and features cpal uses.
|
||||
coreaudio-rs = { version = "0.13", default-features = false, features = ["audio_unit", "core_audio"] }
|
||||
objc2-core-audio = { version = "0.3", default-features = false, features = ["std", "AudioHardware"] }
|
||||
objc2-core-audio-types = { version = "0.3", default-features = false, features = ["std", "CoreAudioBaseTypes"] }
|
||||
@@ -178,20 +99,12 @@ objc2-app-kit = { version = "0.2", features = [
|
||||
] }
|
||||
objc2-foundation = { version = "0.2", features = ["NSArray", "NSData", "NSGeometry"] }
|
||||
|
||||
# Everywhere else: the same device layer over cpal (decision 1920, `sound::output::cpal`). 1857
|
||||
# owns the mix-ahead ring, the render thread and the meters on every platform; only the last
|
||||
# hop — find the default device, open a stream on it, hand back one buffer per cycle — is
|
||||
# platform code, and off macOS that hop is ALSA/PipeWire, WASAPI or the host's own API. cpal is
|
||||
# the layer that already speaks all of them; on macOS it is still not in the graph, because
|
||||
# there its defects (a no-op error callback on the default device, a pinned `DefaultOutput`
|
||||
# unit, the discarded IO-cycle timestamp) are exactly what 1857 went direct to escape. On an
|
||||
# unsupported target cpal compiles to its Null host, which reports no device — the same silent
|
||||
# posture the old `stub.rs` had, without a second file to rot.
|
||||
# Off macOS the device layer's last hop runs over cpal (`sound::output::cpal`). macOS goes direct:
|
||||
# there cpal cannot report or rebuild on a lost default device and drops the IO-cycle timestamp.
|
||||
# An unsupported target gets cpal's Null host, which reports no device.
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
cpal = "0.17"
|
||||
|
||||
# Windows: the journal's and the probes' CPU clocks (decision 2219, `perf::clock`) —
|
||||
# `GetProcessTimes`, `GetThreadTimes` and `GetSystemTimes`, the twins of the `getrusage`,
|
||||
# `clock_gettime` and `host_statistics64` arms. The version is one the graph already carries.
|
||||
# Windows CPU clocks for `perf::clock`: `GetProcessTimes`, `GetThreadTimes`, `GetSystemTimes`.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
|
||||
|
||||
@@ -1,65 +1,19 @@
|
||||
<!--
|
||||
OUR adapters over the reference's own `Interface\FrameXML\ContainerFrame.xml`/`.lua`.
|
||||
|
||||
**This client does not transcribe those files — it EXECUTES them, off the player's own patch
|
||||
chain** (decision 1751; `ui_script::reference_ui` carries the rule, and `benilla.toc` carries
|
||||
the position). The bag windows on screen ARE `ContainerFrame1..12`. What is in THIS file is
|
||||
only benilla's, and only where a reference body reaches for something this client genuinely
|
||||
does not have.
|
||||
|
||||
The rule, restated in one line so it can be applied without re-reading the module: the sourced
|
||||
file's globals stand unmodified; ours, loading after, win where both define a name; and where a
|
||||
reference body needs a benilla answer, it is WRAPPED here — never silently stubbed.
|
||||
|
||||
TWO THINGS THIS FILE USED TO DO AND NO LONGER DOES, both retired by 1751's swap:
|
||||
· It declared `ContainerFrameItemButtonTemplate`, transcribed from ContainerFrame.xml, because
|
||||
the Lua could be sourced and the XML could not. The XML is sourced now, so the reference's
|
||||
own declaration is the live one and the copy is deleted — the whole point of the migration.
|
||||
· It defined `updateContainerFrameAnchors()` as a deliberate no-op, because the reference's
|
||||
body walks twelve `ContainerFrame` windows this client did not build. It builds them, so the
|
||||
reference's own body is correct here and the stub is deleted. Five corpus addons (Bongos,
|
||||
Bongos_ActionBar, the CT_BarMod family) hook that global and call through; they now reach the
|
||||
real thing.
|
||||
|
||||
── The adapters, and why neither is a stub ──────────────────────────────────────────────────
|
||||
`ContainerFrameItemButton_OnEnter` opens with a keyring fork (ref l.615-620):
|
||||
|
||||
if this:GetParent():GetID() == KEYRING_CONTAINER then
|
||||
GameTooltip:SetInventoryItem("player", KeyRingButtonIDToInvSlotID(this:GetID()))
|
||||
CursorUpdate()
|
||||
return
|
||||
end
|
||||
|
||||
That fork exists because the REAL client's keyring items are inventory slots, not container
|
||||
slots, so it has to detour out of the container API to describe one. benilla's keyring is an
|
||||
ordinary container (id -2, decision 0765): its slots come through `GetContainerItemInfo` and
|
||||
`GameTooltip:SetBagItem` like every other bag's — which is exactly why the reference's keyring
|
||||
WINDOW, its verbs and its sounds all take our keyring unchanged, and why this one line is the
|
||||
only thing in that file the swap did not simply inherit. The detour is not merely unavailable
|
||||
here (`KeyRingButtonIDToInvSlotID` is not a binding here) — it is *wrong* here, and supplying
|
||||
the verb would buy an empty tooltip, because our keyring items were never fed into the
|
||||
inventory-slot space it reads.
|
||||
|
||||
So the wrapper below answers the keyring case the way benilla answers every other bag, and
|
||||
hands EVERY other slot straight to the reference's own body. The path the player is actually
|
||||
on — hovering an ordinary bag slot — runs the real client's code, unedited.
|
||||
|
||||
── Named, not hidden: what still does not work through the sourced files ────────────────────
|
||||
· `REPAIR_COST` is read by the reference's repair-mode arm. It is a `GlobalStrings.lua` key and
|
||||
this client sources that file too, so it resolves at runtime — and the arm is unreachable
|
||||
today regardless, since `GameTooltip:SetBagItem`'s repairCost is an INTERIM 0.
|
||||
· `NO_EMPTY_KEYRING_SLOTS` is read by the reference's `PutKeyInKeyRing` when the ring is full,
|
||||
and no such GlobalString exists — 1.12 ships only `NO_EMPTY_KEYRING_SLOTS_ERROR`. That is a
|
||||
REFERENCE BUG, and this client used to un-quirk it. It does not any more: the reference's own
|
||||
file runs, so a full keyring prints the reference's own empty error line. Faithful, and
|
||||
deliberately so (1751: a divergence becomes faithful or becomes a record, never silent).
|
||||
Our adapters over the stock `Interface\FrameXML\ContainerFrame.xml` and `.lua`, which run off
|
||||
the player's patch chain: the bag windows are the stock `ContainerFrame1..12`. This file loads
|
||||
after them, so its definitions win; a stock body that needs a benilla answer is wrapped here.
|
||||
Deviation: benilla's keyring is a container (id -2) where the reference's is inventory slots,
|
||||
so a keyring slot's tooltip comes from `GameTooltip:SetBagItem` like any bag's; the stock
|
||||
keyring fork in `ContainerFrameItemButton_OnEnter` (ContainerFrame.lua l.615-620) reads the
|
||||
inventory-slot space, which our keyring items are not in.
|
||||
A full keyring prints the reference's empty error line: its `PutKeyInKeyRing` reads
|
||||
`NO_EMPTY_KEYRING_SLOTS`, which 1.12 does not ship (it has `NO_EMPTY_KEYRING_SLOTS_ERROR`).
|
||||
That is the reference's behaviour, kept.
|
||||
-->
|
||||
<Ui xmlns="http://www.blizzard.com/wow/ui/">
|
||||
<Script><![CDATA[
|
||||
-- The reference's own body, captured before we shadow the name. Nil on a machine with no
|
||||
-- client data (nothing was sourced) — which must be LOUD at the call, not a quiet no-op:
|
||||
-- a bag hover that silently does nothing is precisely the failure mode that let Bagnon
|
||||
-- ship "working" through four green survey columns.
|
||||
-- The stock body, captured before the name is shadowed. It is nil when no client data was
|
||||
-- sourced, and the call then says so rather than doing nothing.
|
||||
local refItemButtonOnEnter = ContainerFrameItemButton_OnEnter
|
||||
|
||||
function ContainerFrameItemButton_OnEnter(button)
|
||||
@@ -68,9 +22,8 @@
|
||||
end
|
||||
if not button then return end
|
||||
|
||||
-- benilla's keyring is a CONTAINER (0765), so the reference's inventory-slot detour is
|
||||
-- both unavailable and wrong here; this is the same pair of calls its general path
|
||||
-- makes for every other bag, which is all a keyring slot ever needed.
|
||||
-- Our keyring is a container, so its slot takes the stock general path's calls for a
|
||||
-- bag slot instead of the inventory-slot detour.
|
||||
if button:GetParent():GetID() == KEYRING_CONTAINER then
|
||||
if (button:GetRight() or 0) >= (GetScreenWidth() / 2) then
|
||||
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
|
||||
@@ -90,50 +43,30 @@
|
||||
refItemButtonOnEnter(button)
|
||||
end
|
||||
|
||||
-- ══ The three bag verbs benilla answers differently (decisions 0561 + 1751) ═════════
|
||||
--
|
||||
-- `CloseAllBags`, `OpenBackpack` and `CloseBackpack` all exist in the sourced
|
||||
-- `ContainerFrame.lua` too, and benilla's differ for one reason, not three: **0561, the
|
||||
-- director's call that a window which opens your bags opens EVERY equipped bag**, not just
|
||||
-- the backpack. The vendor/bank/mail/trade windows call `OpenBackpack` from their OnShow so
|
||||
-- your bags come up with them and you can sell or deposit from one; the reference's own
|
||||
-- body touches bag 0 alone. `CloseBackpack` is its inverse and has to match it, and
|
||||
-- `CloseAllBags` is the same family's unconditional form.
|
||||
--
|
||||
-- **The `Benilla*` names, and the publish under them, are load ORDER** (1751). Order
|
||||
-- decides every collision and the manifest is the order: this file loads after the
|
||||
-- reference's `ContainerFrame.xml`, so the three publishes at the foot are the ones that
|
||||
-- stand. The bodies lived in our UIParent.lua counterpart until 1988 retired it — a
|
||||
-- harness with no bag stack now meets the loader's stand-ins for the three names
|
||||
-- (`test_ui::UIPARENT_STAND_INS`), not a nil.
|
||||
--
|
||||
-- What CHANGED with the swap is only the plumbing: these used to walk five benilla windows
|
||||
-- by name and stash `wasOpen` on each frame. The windows are the reference's recycled
|
||||
-- `ContainerFrame1..12` now — frame N is a different bag every time it opens — so the
|
||||
-- was-open memory is keyed by BAG ID, and every open and close goes through the
|
||||
-- reference's own `OpenBag`/`CloseBag`/`IsBagOpen` rather than a raw Show/Hide. That buys
|
||||
-- the reference's own guards for free (OpenBag's CanOpenPanels + NotWhileDeadError,
|
||||
-- ToggleBag's `size > 0`) and means an addon replacing those verbs is honoured here too.
|
||||
-- ══ The three bag verbs benilla answers differently ═══════════════════════════════════
|
||||
-- Deviation: `OpenBackpack` opens every equipped bag, not only the backpack, so the
|
||||
-- vendor, bank, mail and trade windows that call it from OnShow bring up every bag to sell
|
||||
-- or deposit from; `CloseBackpack` is its inverse, `CloseAllBags` the unconditional form.
|
||||
-- The was-open memory is keyed by bag id because `ContainerFrame1..12` are recycled across
|
||||
-- bags, and every open and close goes through the stock `OpenBag`/`CloseBag`/`IsBagOpen`,
|
||||
-- so their guards and any addon that replaces them apply.
|
||||
BENILLA_BAG_WAS_OPEN = {}
|
||||
|
||||
-- The UNCONDITIONAL close, called by ShowUIPanel's native-center branch above: a centre
|
||||
-- frame takes the whole screen, so nothing is being "restored afterwards" the way a vendor
|
||||
-- window restores the bags it opened, and the was-open memory is deliberately ignored.
|
||||
-- Range 0..NUM_CONTAINER_FRAMES rather than the old five names — the reference's own
|
||||
-- CloseAllBags covers the bank bags too (l.702-707), and now so does this.
|
||||
-- The unconditional close, from the stock ShowUIPanel's centre branch: it ignores the
|
||||
-- was-open memory and covers the bank bags, as the reference's CloseAllBags does
|
||||
-- (l.702-707).
|
||||
function BenillaCloseAllBags()
|
||||
for bagId = 0, (NUM_CONTAINER_FRAMES or -1) do
|
||||
CloseBag(bagId)
|
||||
end
|
||||
end
|
||||
|
||||
-- OpenBackpack — 0561's all-bags form. A bag already open is REMEMBERED and left alone, so
|
||||
-- the paired CloseBackpack does not close a window the player opened themselves; that
|
||||
-- memory is the reference's own `backpackWasOpen`, one entry per bag instead of one flag.
|
||||
-- A bag already open is remembered and left alone, so CloseBackpack leaves open a window
|
||||
-- the player opened; the reference's `backpackWasOpen`, one entry per bag.
|
||||
function BenillaOpenBackpack()
|
||||
for bagId = 0, (NUM_BAG_FRAMES or -1) do
|
||||
-- The backpack always exists; an equipped-bag window only when a bag is in the
|
||||
-- slot — `OpenBag` makes the same test itself, but the memory has to agree with it.
|
||||
-- OpenBag's own test, repeated so the memory agrees with it: the backpack always,
|
||||
-- another bag only when its slot holds one.
|
||||
if bagId == 0 or GetContainerNumSlots(bagId) > 0 then
|
||||
if IsBagOpen(bagId) then
|
||||
BENILLA_BAG_WAS_OPEN[bagId] = 1
|
||||
@@ -153,9 +86,8 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- The publish — after the reference's container file, so these are the definitions that
|
||||
-- stand (the stock `ShowUIPanel` calls `CloseAllBags` and the vendor's OnShow
|
||||
-- `OpenBackpack`, and they reach 0561's bodies).
|
||||
-- Published after the stock ContainerFrame.lua has loaded, so these are the definitions its
|
||||
-- callers reach (the stock `ShowUIPanel`'s `CloseAllBags`, a vendor's `OpenBackpack`).
|
||||
OpenBackpack = BenillaOpenBackpack
|
||||
CloseBackpack = BenillaCloseBackpack
|
||||
CloseAllBags = BenillaCloseAllBags
|
||||
|
||||
@@ -1,106 +1,20 @@
|
||||
<Ui>
|
||||
<!--
|
||||
The GAME MENU — the frame ESC opens (decision 0674). The last hole in the game shell's
|
||||
outermost layer: until now ESC's chain ran out of things to close and did nothing, and the
|
||||
Main Menu micro button was drawn but dead (our since-retired MicroMenu.xml's SCOPE note).
|
||||
|
||||
PROVENANCE (0985 — the three-source split, stated the same way in every era-shaped
|
||||
window; OptionsFrame.xml carries the sibling block):
|
||||
· STRUCTURE & BEHAVIOR — the 1.15 era client (wow-ui-source-era @ 1.15.9.68940): the
|
||||
ladder shape, its groups and gaps, which entries exist (the SCOPE section below).
|
||||
If 1.15 has the mechanism, it STAYS.
|
||||
· CHROME MECHANISM — this window predates 1.13: the 1.12 client's OWN GameMenuFrame
|
||||
is the chrome ground truth (the dialog backdrop, header plate, red buttons — the
|
||||
same dialog family the 1.13/1.14 options windows kept), quoted below.
|
||||
· ART BYTES — the 1.12 MPQ chain only; nothing later-client at runtime (0978's law).
|
||||
|
||||
Ground truth, all quoted rather than re-derived:
|
||||
· **The frame** — ref `GameMenuFrame.xml` (patch.MPQ, extracted): 195x246, `frameStrata`
|
||||
DIALOG, the UI-DialogBox backdrop with the 11/12/12/11 insets and 32px tile/edge, the
|
||||
256x64 UI-DialogBox-Header at TOP +12 with MAIN_MENU centered 14px down it, and the
|
||||
eight-button ladder — first button CENTER on the frame's TOP at (0,-37), each next one
|
||||
TOP-to-BOTTOM at (0,-1), and Return to Game hung (0,-16) below Exit Game (the one gap in
|
||||
the stack, which is what makes the last entry read as separate).
|
||||
· **The button** — ref `GameMenuButtonTemplate` (UIPanelTemplates.xml l.403-410): a 144x21
|
||||
`UIPanelButtonTemplate` (the UI-Panel-Button Up/Down/Disabled/Highlight four-state art,
|
||||
TexCoords 0..0.625 / 0..0.6875) whose label trio is overridden to
|
||||
GameFontHighlight/GameFontHighlight/GameFontDisable — i.e. the menu's labels are WHITE,
|
||||
not the gold a normal panel button wears, and grey when disabled.
|
||||
· **The labels** — GlobalStrings.lua (patch-2.MPQ), verbatim: VIDEOOPTIONS_MENU "Video
|
||||
Options", SOUNDOPTIONS_MENU "Sound Options", UIOPTIONS_MENU "Interface Options",
|
||||
MACROS "Macros", LOGOUT "Logout", EXIT_GAME "Exit Game",
|
||||
RETURN_TO_GAME "Return to Game", MAIN_MENU "Main Menu". Declared as the real globals
|
||||
(they are addon-visible API, decision 0068) and carried on each button's own
|
||||
`text=` attribute — which this engine resolves as a global-string lookup exactly as
|
||||
the reference does (decision 0995, `loader/widgets.rs`'s `resolve_text`).
|
||||
· **The sounds** — the ref's own OnClick lines: igMainMenuOption (the five option buttons),
|
||||
igMainMenuLogout, igMainMenuQuit, igMainMenuContinue. All four kits verified present in
|
||||
SoundEntries.dbc (patch-2.MPQ) alongside the igMainMenuOpen/Close pair already in use.
|
||||
· **The two logout dialogs** — ref `StaticPopup.lua` l.564-600 (CAMP + QUIT) is the
|
||||
SPECIFICATION for them: the two dialog keys, the field names, the 20 s timeout and the
|
||||
asymmetric button sets are contract, and the entries carrying them are written here in
|
||||
our own shape on the shared popup engine (the stock StaticPopup), whose countdown-text branch
|
||||
already lists both `which` names. Their driver events are the reference's own
|
||||
(`re/events/event-catalog.tsv`: 276 PLAYER_CAMPING, 277 PLAYER_QUITING,
|
||||
278 LOGOUT_CANCEL), fired by `crate::ui_logout` off `SMSG_LOGOUT_RESPONSE`.
|
||||
|
||||
NAMES are the reference's, unprefixed (`GameMenuFrame`, `GameMenuButton*`) — the same call
|
||||
our retired MicroMenu.xml made for its straight transcriptions, and required here besides: `ToggleGameMenu`
|
||||
and `GameMenuFrame` are Era API surface an addon addresses by name.
|
||||
|
||||
SCOPE / divergences (noted, not bugs):
|
||||
- **The ladder is the ERA menu's** (director's call on the 0951 review — supersedes 0951
|
||||
§3's five-button collapse): the Era client's own ESC menu shape, greyed where benilla
|
||||
has nothing behind the entry yet. Geometry quoted from the era dump @ 1.15.9 (Gethe
|
||||
wow-ui-source, classic_era, the 0950 pin):
|
||||
· Blizzard_SharedXML/Classic/Frame/MainMenuFrameTemplates.xml l.18-33 — the menu is a
|
||||
VerticalLayoutFrame, buttonTemplate MainMenuFrameButtonTemplate, padding
|
||||
top/bottom/left/right = 32/28/28/28, spacing 0; l.11-16 — the button is a 144x21
|
||||
UIPanelButtonTemplate with the white/white/grey label trio (the SAME art + trio the
|
||||
1.12 GameMenuButtonTemplate carries — no era-only art in this menu at all).
|
||||
· Shared/Frame/MainMenuFrameTemplates.lua l.61-71 — AddSection() puts a 20-unit gap
|
||||
(the next button's topPadding) between groups; AddCloseButton uses the same 20.
|
||||
· Shared/GameMenuFrame.lua l.62-162 — the Era ladder: Options · [gap] · AddOns, Edit
|
||||
Mode, Support, Macros · [gap] · Logout, Exit Game · [gap] · Return to Game. (Store /
|
||||
Rewards / What's New / Ratings are structurally off in Era — store disabled, no
|
||||
account store, IsStandard() false, koKR only.)
|
||||
· The layout math those constants produce, precomputed here (this engine has no layout
|
||||
frames) — for OUR seven rungs, the era's eight minus AddOns (next bullet): width
|
||||
28+144+28 = 200; button tops 32/73/94/115/156/177/218; height 218+21+28 = 267.
|
||||
Anchored as a TOPLEFT chain so the gaps read in the XML.
|
||||
· No Key Bindings rung — the era menu's own shape: 1.15 ceded the entry to the
|
||||
Settings panel, and since decision 1008 ours lives there too (the Options window's
|
||||
Keybindings category, KeyBindingsPage.xml). 0997 had carved the 1.13/1.14 seat back
|
||||
in for its standalone window; that seat left with the window.
|
||||
· No AddOns rung — the director's call: the char-select AddOns screen
|
||||
(`char_select::addons`, the reference's own glue-side home for this list) is the only
|
||||
addon UI. The in-game panel 1197 put behind this rung (AddonList.xml) is retired,
|
||||
and the rung left with it — 1.12's own ESC menu has no AddOns row either.
|
||||
· The header — Classic/Dialog/DialogTemplates.xml l.11-43 (ClassicDialogHeaderTemplate):
|
||||
the same 256x64 UI-DialogBox-Header art, text = MAINMENU_BUTTON, swapped to
|
||||
GameFontNormalMed1 (13px) by MainMenuFrameMixin:OnLoad. Both header idioms (1.12's
|
||||
TOP+12 texture / era's 39-tall frame with the art centered) seat the art within half
|
||||
a unit of each other; the 1.12 transcription is kept, the font and string are era's.
|
||||
- **Edit Mode / Support ship DISABLED** — nothing behind them yet
|
||||
(`GameMenuButton_Pending`): the template's own `-Disabled` art + grey label, exactly
|
||||
how the era menu greys a dead entry (SetEnabled(false) → DisabledFont). Each goes live
|
||||
by deleting one `GameMenuButton_Pending` call and writing its OnClick.
|
||||
- The Options label stays a literal "Options": 1.12 GlobalStrings has no era
|
||||
GAMEMENU_OPTIONS (checked; its OPTIONS_MENU is "Options Menu", a different string).
|
||||
- The ref frame is `toplevel="true" movable="true" parent="UIParent"`. `parent="UIParent"` is
|
||||
ours too, and it attaches for real: the loader does read a cross-file `parent=`, resolving
|
||||
the name by global lookup at load (decision 1734), and the frame below declares it. Only
|
||||
`movable` is dropped, and it is inert in the ref too (GameMenuFrame defines no drag
|
||||
scripts, so it never actually moves).
|
||||
`frameStrata="DIALOG"` is what puts it over the world UI, and that is kept.
|
||||
**The "no frame-level raise" half of that reason is gone**: the raise law is built
|
||||
(`script::object::toplevel`, wow-re `ui/scratch/toplevel-raise.md`), the loader honours
|
||||
`toplevel="true"`, and the frame below carries it — so the Esc menu does come to the
|
||||
front over any other DIALOG-strata frame it overlaps.
|
||||
The game menu, the frame ESC opens. Its chrome is the reference GameMenuFrame.xml's: DIALOG
|
||||
strata, the UI-DialogBox backdrop (insets 11/12/12/11, 32px tile and edge), the 256x64
|
||||
header at TOP +12 with the title 14px down it, the reference's click sounds, and the stock
|
||||
`GameMenuButtonTemplate`, which the era's MainMenuFrameButtonTemplate matches in art,
|
||||
size and label fonts.
|
||||
Deviation: the ladder is the 1.15 era client's ESC menu (GameMenuFrame.lua l.62-162),
|
||||
because its settings screens replace 1.12's, which are much worse to use. It keeps the
|
||||
era's layout numbers, padding 32 top and 28 elsewhere (MainMenuFrameTemplates.xml
|
||||
l.18-33) and a 20-unit gap between groups (MainMenuFrameTemplates.lua l.61-71), and drops
|
||||
the era's AddOns rung, because the character-select AddOns screen is the only addon UI;
|
||||
Key Bindings is in the options window, as in the era.
|
||||
Names are the reference's, unprefixed: addons address `GameMenuFrame` by name.
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
-- The reference GlobalStrings (patch-2.MPQ Interface\FrameXML\GlobalStrings.lua), quoted
|
||||
-- verbatim. `X = X or "..."` so a later real strings table wins, the retired MicroMenu.xml's idiom.
|
||||
-- Label strings as `X = X or "..."`, so a stock GlobalStrings value wins where one exists.
|
||||
MAIN_MENU = MAIN_MENU or "Main Menu"
|
||||
HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode"
|
||||
GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support"
|
||||
@@ -110,50 +24,29 @@
|
||||
RETURN_TO_GAME = RETURN_TO_GAME or "Return to Game"
|
||||
CANCEL = CANCEL or "Cancel"
|
||||
|
||||
-- One label setter for a button with nothing behind it yet (Edit Mode and Support): set
|
||||
-- the ref's text, then disable (grey label + the template's -Disabled art). Making it
|
||||
-- live is deleting the call and writing its ref OnClick.
|
||||
-- Labels and disables a rung with nothing behind it yet (Edit Mode, Support), greyed on the
|
||||
-- template's -Disabled art the way the era menu greys a dead entry.
|
||||
function GameMenuButton_Pending(button, text)
|
||||
button:SetText(text)
|
||||
button:Disable()
|
||||
end
|
||||
|
||||
-- ══ THE THREE STOCK CONSUMERS THAT ASK "IS AN OPTIONS WINDOW OPEN?" (decision 2177) ══
|
||||
-- All three are the reference's own, off the chain, and all three name the reference's
|
||||
-- three windows literally. Two of those names are now REAL hidden stock frames
|
||||
-- (`SoundOptionsFrame` since 2147, `UIOptionsFrame` since 2115) and the third,
|
||||
-- `OptionsFrame`, became one when the stock VIDEO window joined the manifest — so the
|
||||
-- whole chain now reads "no", always, while the window the player actually opens is up.
|
||||
-- Until then `OptionsFrame` WAS our window and every one of them answered by accident.
|
||||
--
|
||||
-- **Wrappers, not replacements.** Each stock function keeps running and keeps deciding;
|
||||
-- ours adds the one term the reference could not know about. A rewrite would be thirty
|
||||
-- lines of the reference's ladder copied into our tree to drift.
|
||||
--
|
||||
-- **HERE, not in `OptionsFrame.xml`, because of load order.** `UpdateMicroButtons` is
|
||||
-- defined by `MainMenuBarMicroButtons.xml`, manifest l.576 — AFTER our options window at
|
||||
-- l.522, where a wrapper would capture a nil. This file is the manifest's LAST row
|
||||
-- (l.1273), so all three exist. It is also the right home on the merits: this is the ESC
|
||||
-- menu, and these three are exactly "the ESC ladder and the micro button know about our
|
||||
-- options window".
|
||||
-- ══ Our options window in the stock "is an options window open?" checks ══
|
||||
-- The three stock functions name the reference's options windows, which here are hidden
|
||||
-- stock frames, so each is wrapped to add `BenillaOptionsFrame` and still runs the stock
|
||||
-- body. Wrapped here because this file loads last: `UpdateMicroButtons`
|
||||
-- (MainMenuBarMicroButtons.xml) loads after our options window.
|
||||
local Ref_IsOptionFrameOpen = IsOptionFrameOpen
|
||||
local Ref_ToggleGameMenu = ToggleGameMenu
|
||||
local Ref_UpdateMicroButtons = UpdateMicroButtons
|
||||
|
||||
-- The one term all three add. Existence-guarded because these three wrappers stand in a
|
||||
-- file that can be seated without the options window: in the client `BenillaOptionsFrame`
|
||||
-- is manifest row 38 and this file is the last row, so it is always real — but a test kit
|
||||
-- that seats the game menu alone must get "no options window is open", not a raise inside
|
||||
-- the ESC handler.
|
||||
-- Guarded for a test kit that loads the game menu without the options window.
|
||||
local function BenillaOptions_IsOpen()
|
||||
return BenillaOptionsFrame and BenillaOptionsFrame:IsVisible()
|
||||
end
|
||||
|
||||
-- `UIParent.lua:996`. One caller: `CloseAllWindows_WithExceptions`, which passes
|
||||
-- `ignoreCenter = 1` when an options window is up so the ESC sweep does not close the
|
||||
-- centre panel out from under it. Ours IS the centre panel (`UIPanelWindows` row,
|
||||
-- `area = "center"`), so answering "no" would have ESC close it through the sweep instead
|
||||
-- of through its own rung, discarding pending edits with no Cancel semantics.
|
||||
-- `UIParent.lua:996`, read by `CloseAllWindows_WithExceptions`: a yes keeps the ESC sweep
|
||||
-- off the centre panel, which our window is, so ESC closes it through its own rung.
|
||||
function IsOptionFrameOpen()
|
||||
if BenillaOptions_IsOpen() then
|
||||
return 1
|
||||
@@ -161,20 +54,13 @@
|
||||
return Ref_IsOptionFrameOpen()
|
||||
end
|
||||
|
||||
-- `UIParent.lua:1465-1497`. The ESC ladder, and the rung matters as much as the answer:
|
||||
-- the reference tries the static popup FIRST, then an open options window, then the game
|
||||
-- menu. So this runs that first rung itself and returns if it fired — `StaticPopup_Escape -- Pressed` is a pure scan when nothing is shown (`StaticPopup.lua:1879-1893`), so the
|
||||
-- delegate below re-running it costs nothing and changes nothing — then takes the options
|
||||
-- rung, then hands the rest of the ladder back untouched.
|
||||
--
|
||||
-- The close is our own Close button's click, which is the same discard-and-hide the
|
||||
-- reference reaches through `OptionsFrameCancel:Click()`: our `<OnHide>` drops the pending
|
||||
-- page. (That alias is retired — `OptionsFrameCancel` is the stock video window's own
|
||||
-- button now, and ours loading later would have clobbered it.)
|
||||
-- `UIParent.lua:1465-1497`, the ESC ladder: static popup first, then an open options
|
||||
-- window, then the rest of the stock ladder. The stock body re-runs the popup rung, a pure
|
||||
-- scan when nothing is shown (`StaticPopup.lua:1879-1893`). Our Close button's click is
|
||||
-- the discard-and-hide the reference reaches through `OptionsFrameCancel:Click()`.
|
||||
function ToggleGameMenu(clicked)
|
||||
if clicked then
|
||||
-- The reference's `clicked` arm closes an open options window and then goes on to
|
||||
-- toggle the game menu, rather than returning. Same here.
|
||||
-- As the reference's `clicked` arm: close an options window, then still toggle.
|
||||
if BenillaOptions_IsOpen() then
|
||||
BenillaOptionsFrameCloseButton:Click()
|
||||
end
|
||||
@@ -190,17 +76,9 @@
|
||||
return Ref_ToggleGameMenu(clicked)
|
||||
end
|
||||
|
||||
-- `MainMenuBarMicroButtons.lua:47-58`. A POST-hook that can only ever push the button, never
|
||||
-- release it: the reference has already decided from the six frames it knows, and this adds
|
||||
-- the seventh. Written this way round so it cannot fight that decision — a wrapper that also
|
||||
-- set NORMAL would be a second copy of the reference's whole `or` chain.
|
||||
--
|
||||
-- **Existence-guarded, and only here.** The shipped manifest always carries
|
||||
-- `MainMenuBarMicroButtons.xml`, so in the client `Ref_UpdateMicroButtons` and
|
||||
-- `MainMenuMicroButton` are always real. A partial test kit that seats this file and not
|
||||
-- the micro bar is the case the guards are for, and a guard is the honest answer there:
|
||||
-- there is no micro button to push, so there is nothing to do — as against a raise inside
|
||||
-- an unrelated window's OnShow, which is what an unguarded wrapper turns that into.
|
||||
-- `MainMenuBarMicroButtons.lua:47-58`: a post-hook that only ever pushes the Main Menu
|
||||
-- button, never releases it, so the stock decision over its six frames stands and ours adds
|
||||
-- the seventh. Guarded for a test kit that loads this file without the micro bar.
|
||||
function UpdateMicroButtons()
|
||||
if Ref_UpdateMicroButtons then
|
||||
Ref_UpdateMicroButtons()
|
||||
@@ -210,35 +88,20 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- The CAMP/QUIT dialogs and the three-event driver MOVED OUT, to our UIParent.lua counterpart (the stock UIParent.lua since 1988) — the
|
||||
-- reference's own homes for both (StaticPopup.lua l.564/586; UIParent.lua l.304-315).
|
||||
-- They were here only because this is where they were first needed, and 1751 moving them
|
||||
-- is one less thing to carry when this window follows them onto the chain.
|
||||
]]></Script>
|
||||
|
||||
<!-- The era header text size (Classic/Dialog/DialogTemplates.xml + the OnLoad font swap):
|
||||
GameFontNormalMed1 = SystemFont_Med2 = FRIZQT 13, gold, the MasterFont shadow. Era's
|
||||
name, declared here beside its one consumer (the 0951 residue convention: Fonts.xml
|
||||
stays the complete 1.12 set). -->
|
||||
<!-- The era's header font, GameFontNormalMed1 (FRIZQT 13, gold): an era name 1.12's Fonts.xml
|
||||
lacks, declared beside its one consumer. -->
|
||||
<Font name="GameFontNormalMed1" inherits="GameFontNormal" virtual="true">
|
||||
<FontHeight><AbsValue val="13"/></FontHeight>
|
||||
</Font>
|
||||
|
||||
<!-- `GameMenuButtonTemplate` MOVED OUT, to UIPanelTemplates.xml — the reference's own file for
|
||||
it (ref UIPanelTemplates.xml l.403-410), and now ColorPickerFrame.xml's Okay/Cancel need it
|
||||
too, from far above this file's LAST-in-the-toc seat. It lived here, with its four state
|
||||
textures inlined, only because `UIPanelButtonTemplate` had not been transcribed yet; it now
|
||||
has been, so the reference's actual body (`inherits="UIPanelButtonTemplate"`, 144x21, the
|
||||
white/white/grey label trio) replaces the private copy verbatim. Era's
|
||||
MainMenuFrameButtonTemplate (Classic/Frame/MainMenuFrameTemplates.xml l.11-16) is that same
|
||||
button — same art, same trio, same size — so the header's ART BYTES note is unchanged. -->
|
||||
|
||||
<!-- `toplevel` is the ref's own (GameMenuFrame.xml l.3) and was dropped here: without the bit
|
||||
a DIALOG-strata window opened over another one stays behind it (the B180/Bagnon class,
|
||||
decision 1318). Restored alone; the ref's `movable`/`parent` stay off deliberately. -->
|
||||
<!-- The reference frame's attributes (GameMenuFrame.xml l.3) except `movable`, which is inert
|
||||
there: the reference defines no drag scripts. -->
|
||||
<Frame name="GameMenuFrame" parent="UIParent" frameStrata="DIALOG" toplevel="true" enableMouse="true" hidden="true">
|
||||
<!-- The era layout engine's own numbers (header SCOPE note): 28+144+28 wide, 32-top /
|
||||
28-bottom padding around our seven 21-unit rungs with three 20-unit section gaps. -->
|
||||
<!-- The era layout: 28+144+28 wide; 32 top and 28 bottom padding around seven 21-unit
|
||||
rungs and three 20-unit section gaps. -->
|
||||
<Size><AbsDimension x="200" y="267"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
|
||||
@@ -252,9 +115,8 @@
|
||||
<Size><AbsDimension x="256" y="64"/></Size>
|
||||
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="12"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<!-- Era swaps the header text to 13px (MainMenuFrameMixin:OnLoad →
|
||||
SetHeaderFont(GameFontNormalMed1)); the seat is the 1.12 one (within half a
|
||||
unit of era's — header SCOPE note). -->
|
||||
<!-- The era's 13px header font (MainMenuFrameMixin:OnLoad) on the 1.12 seat, which
|
||||
is within half a unit of the era's. -->
|
||||
<FontString name="GameMenuFrameTitle" inherits="GameFontNormalMed1" text="MAIN_MENU">
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativeTo="GameMenuFrameHeader"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor>
|
||||
@@ -263,18 +125,10 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The era ladder minus its AddOns rung (header SCOPE notes): Options · gap ·
|
||||
Edit Mode, Support, Macros · gap · Logout, Exit Game · gap · Return to Game.
|
||||
A TOPLEFT chain at the era layout engine's numbers — x 28, first top -32, 0
|
||||
between group members, -20 at each section break. Live entries wire the era
|
||||
OnClick intent; pending ones grey out through GameMenuButton_Pending until
|
||||
something exists behind them. -->
|
||||
|
||||
<!-- Options — live: the era GameMenuFrame.lua l.88 entry (ShowUIPanel(SettingsPanel)
|
||||
there, BenillaOptionsFrame here) plus an explicit HideUIPanel first. The ref leans on
|
||||
the center-replacement rule for the swap (ShowUIPanel l.697-702: native-center
|
||||
frames replace each other); hiding the menu explicitly says the same thing
|
||||
without relying on the eviction path. -->
|
||||
<!-- The era's Options entry (GameMenuFrame.lua l.88), opening our options window. A
|
||||
literal label: 1.12 has no GAMEMENU_OPTIONS, and its OPTIONS_MENU is a different
|
||||
string, "Options Menu". -->
|
||||
<Button name="GameMenuButtonOptions" inherits="GameMenuButtonTemplate" text="Options">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="28" y="-32"/></Offset></Anchor>
|
||||
@@ -304,8 +158,7 @@
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonSupport" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<!-- LIVE since decision 0983 (MacroFrame.xml). The ref's own body
|
||||
(GameMenuFrame.xml l.118-122): hide the menu, then ShowMacroFrame(). -->
|
||||
<!-- The reference's body (GameMenuFrame.xml l.118-122). -->
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
PlaySound("igMainMenuOption")
|
||||
@@ -315,10 +168,8 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The three that are live. The ref's OnShow on both Logout and Quit disables the
|
||||
button while either logout dialog is already up (you cannot re-arm a running
|
||||
countdown); it runs on the menu's own show, which is when a re-opened menu has to
|
||||
re-read that state. -->
|
||||
<!-- The reference's OnShow disables Logout and Exit Game while a CAMP or QUIT countdown
|
||||
is up, so a running countdown cannot be re-armed. -->
|
||||
<Button name="GameMenuButtonLogout" inherits="GameMenuButtonTemplate" text="LOGOUT">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonMacros" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
|
||||
@@ -369,23 +220,14 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
<!-- ref GameMenuFrame OnShow/OnHide, verbatim: the micro row's Main Menu button pushes in
|
||||
while the menu is up, and the bag bar greys out under it (you cannot open bags from
|
||||
behind the menu — ShowUIPanel's center gate would refuse the window anyway, so the
|
||||
greying is the visible half of a rule the panel manager already enforces). Guarded, in
|
||||
the `CloseMenus and CloseMenus()` posture our retired panel file used: a headless test
|
||||
loads this file without the action bar behind it. -->
|
||||
<!-- The reference's OnShow/OnHide: the Main Menu micro button pushes in and the bag
|
||||
buttons grey out while the menu is up. Guarded for a test that loads this file
|
||||
without the action bar. -->
|
||||
<Scripts>
|
||||
<OnShow>
|
||||
-- The shared era-window scale first (ERA_WINDOW_SCALE, OptionsFrame.xml — loaded
|
||||
-- before this file): the era menu has no scale of its own (GameMenuFrame.xml +
|
||||
-- MainMenuFrameTemplates, grepped clean), it draws at UIParent's px-per-unit —
|
||||
-- the SAME density as the era options window, whose checkFit caps at 1. The knob
|
||||
-- is our stand-in for that UIParent scale; without it the menu drew 28% larger
|
||||
-- relative to the options window than the era pair ("esc menu scale too big" —
|
||||
-- director, 2026-08-04). `or 1` guards a load-order regression only. NOTE: this
|
||||
-- lives INSIDE the existing OnShow — a second Scripts/OnShow block on the same
|
||||
-- frame would silently REPLACE this one (SetScript semantics: last wins).
|
||||
-- Deviation: ERA_WINDOW_SCALE (OptionsFrame.xml, loaded earlier) stands in for the
|
||||
-- UIParent scale the era draws this menu and its options window at.
|
||||
-- A second OnShow on this frame would replace this one: the last SetScript wins.
|
||||
this:SetScale(ERA_WINDOW_SCALE or 1)
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end
|
||||
if Disable_BagButtons then Disable_BagButtons() end
|
||||
|
||||
@@ -1,77 +1,24 @@
|
||||
<Ui>
|
||||
<!--
|
||||
The KEY BINDINGS page (decision 1008, superseding 0997's standalone window) — the
|
||||
Options window's Keybindings category, over the engine's binding table
|
||||
(benilla_ui::script::keybind + crate::bindings — GetNumBindings/GetBinding/SetBinding/
|
||||
GetBindingKey/GetBindingAction/LoadBindings/SaveBindings/GetCurrentBindingSet, the 1.12
|
||||
Lua API verbatim). This file is the page MODULE: the templates and the whole script; the
|
||||
page's static furniture (the rows, the checkbox row, the scroll seam, the search rows)
|
||||
lives in OptionsFrame.xml as ordinary children of its Keybindings body, inheriting these
|
||||
templates (manifest order puts this file first). Rows are the HONEST TREE: only commands
|
||||
the engine really dispatches are registered, so only they (and only their non-empty
|
||||
categories) appear.
|
||||
|
||||
PROVENANCE (0985 — the three-source split; OptionsFrame.xml carries the sibling block):
|
||||
· STRUCTURE & BEHAVIOR — the era Settings panel's own Keybindings page (wow-ui-source-era
|
||||
@ 1.15.9.68940: Blizzard_SettingsDefinitions_Frame/Keybindings.lua + Classic/
|
||||
KeybindingsOverrides.lua + Blizzard_Settings_Shared/Blizzard_Keybindings.*): 1.15 put
|
||||
keybindings INSIDE the Options window — the director's call this move executes — as
|
||||
ONE list of EXPANDABLE per-category sections (SettingsKeybindingSectionMixin over
|
||||
SettingsExpandableSectionMixin; 25-tall rows, sections COLLAPSED by default, categories
|
||||
in first-appearance order, a bar's section existing only when the bar does — their
|
||||
PROXY_SHOW_ACTIONBAR_N gate is our honest tree), the Character Specific Keybindings
|
||||
checkbox as the page's first row (PROXY_CHARACTER_SPECIFIC_BINDINGS, confirm popup on
|
||||
UNCHECK only), and per-binding search tags whose category REDIRECTS to this page
|
||||
(Blizzard_SettingsPanel.lua l.578 `category.redirectCategory or category`) with the
|
||||
matched rows LIVE in the results.
|
||||
· CAPTURE & SET LAW — 1.12's own Blizzard_BindingUI (extracted from patch.MPQ, 0997),
|
||||
KEPT over the era's listener where they differ (the game's fidelity truth is 1.12;
|
||||
the window furniture is the era's): click a key capsule to arm; the next key or
|
||||
mouse-3/4/5 press binds IMMEDIATELY (the host capture seam hands the canonical
|
||||
ALT-CTRL-SHIFT chord to KeyBindings_OnHostKey); a stolen key names its victim in red
|
||||
(KEY_UNBOUND_ERROR) only when the victim goes bare; left/right clicks select/deselect,
|
||||
never bind; lone modifiers are ignored; the wheel binds like any other key, to ANY
|
||||
command (decision 1295 — SetBinding refuses nothing but a key string that is not a key,
|
||||
and the KEYBINDINGFRAME_MOUSEWHEEL_ERROR arm below is that refusal's message, not a
|
||||
wheel rule); ESC while armed BINDS Escape (1.12's OnKeyDown has no ESCAPE filter — the
|
||||
era's ESC-cancels law is the one era behavior NOT taken, recorded in 1008; the Unbind
|
||||
Key button is the clearing affordance). ESC with nothing armed never reaches any of
|
||||
this: the ladder is the TOGGLEGAMEMENU binding, and the armed seam swallows the press.
|
||||
· ART BYTES — the 1.12 MPQ chain only (0978/0985): the capsules wear 1.12's own
|
||||
UIPanelButtonTemplate2 panel-button art (the Classic client's silver stretch set —
|
||||
UI-Silver-Button-Up/-Down/-Highlight/-Select, a 9-slice — is VERIFIED ABSENT from
|
||||
the 1.12 chain: 0997, re-pinned and reverted-to-red in 1088 after 1085's gray-dress
|
||||
detour), selection is 1.12's LockHighlight glow; section headers wear the 1.12
|
||||
collapsible-list dress — the QuestLog's own UI-PlusButton/UI-MinusButton square
|
||||
and gold label.
|
||||
|
||||
COMMIT LAW (1008, the page's one big behavior change from 0997): binds are LIVE-COMMIT.
|
||||
The options window's whole grammar writes live (SetCVar on click, no Okay/Cancel pair),
|
||||
and the era Settings panel commits keybinds as they land too — so every successful
|
||||
bind/steal/unbind/reset saves the current set to disk at once
|
||||
(SaveBindings(GetCurrentBindingSet())); 0997's Okay/Cancel/revert and the
|
||||
CONFIRM_LOSE_BINDING_CHANGES popup die with the standalone window. The two destructive
|
||||
gestures keep their confirms: Reset To Default (the page Defaults button) and leaving
|
||||
the character-specific set (which permanently deletes it).
|
||||
|
||||
All strings are the 1.12 GlobalStrings globals (the VM executes the real file at boot)
|
||||
through the `getglobal(...) or raw` fallback (the era's own `_G[x] or x` posture), so a
|
||||
headless test without GlobalStrings still renders raw tokens.
|
||||
|
||||
SCOPE / divergences (noted, not bugs — the ledger is 1008):
|
||||
- No Quick Keybind Mode row (era furniture over an overlay mechanism benilla lacks).
|
||||
- 1.12's BLANK spacer-headers between multibar blocks are not modeled — both bottom
|
||||
bars sit under the one MultiActionBar section.
|
||||
- 1.12's "Key Bindings for <name>" title swap died with the standalone window: the page
|
||||
title stays the category's, the checkbox is the character-set state (era's own shape).
|
||||
- Search shows at most KEYBINDINGS_SEARCH_ROWS matched bindings (static row pool; the
|
||||
era's results list scrolls, ours doesn't yet) — the group head jumps to the full page.
|
||||
The key bindings page: the options window's Keybindings category, over the engine's
|
||||
binding table and the 1.12 binding API. This file holds the templates and the script; the
|
||||
rows are children of the Keybindings body in OptionsFrame.xml, which loads after it. Only
|
||||
commands the engine dispatches are registered, so only they and their categories appear.
|
||||
Deviation: the page is the era Settings panel's Keybindings page (Keybindings.lua,
|
||||
KeybindingsOverrides.lua), because 1.12's settings screens are much worse to use. It is
|
||||
one list of collapsible category sections, the Character Specific Keybindings checkbox as
|
||||
its first row, and search results that open this page.
|
||||
Capture and set follow 1.12's Blizzard_BindingUI where the two differ: a capsule click
|
||||
arms, the next key or mouse 3/4/5 press binds at once, left and right clicks only select,
|
||||
the wheel binds like any key, and ESC while armed binds Escape (1.12's OnKeyDown has no
|
||||
ESCAPE filter; Unbind Key clears).
|
||||
Deviation: every bind, unbind and reset saves at once, as the era panel commits, where
|
||||
1.12 had Okay and Cancel; only Reset To Default and leaving the character set confirm.
|
||||
-->
|
||||
|
||||
<!-- One KEY CAPSULE (1.12 KeyBindingFrameBindingButtonTemplate: UIPanelButtonTemplate2 at
|
||||
180x22 with the small-highlight font trio; its RegisterForClicks takes all five
|
||||
buttons so middle/4/5 presses reach the capture law). The SELECTED state is 1.12's
|
||||
LockHighlight — the ADD highlight held on. -->
|
||||
<!-- A key capsule, 1.12's KeyBindingFrameBindingButtonTemplate: UIPanelButtonTemplate2 art at
|
||||
180x22 with the small font trio, registered for all five buttons so a middle, 4 or 5
|
||||
press reaches the capture. Selected is its LockHighlight, the ADD highlight held on. -->
|
||||
<Button name="KeyBindingButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="180" y="22"/></Size>
|
||||
<ButtonText name="$parentText"/>
|
||||
@@ -87,14 +34,10 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- One LIST ROW, dual-mode (the era list mixes section headers and binding rows in one
|
||||
25-tall rhythm — SettingsKeybindingSectionInitializer:GetExtent's single bindingHeight).
|
||||
BIND mode: command name 170 wide at LEFT, the two capsules at 175/355 (0997's seats,
|
||||
kept). HEADER mode: the whole row is one toggle button wearing 1.12's own collapsible
|
||||
dress — the QuestLogTitleButton's 16x16 UI-Plus/MinusButton square at LEFT and the gold
|
||||
label (the era's Options_ListExpand arrows have no 1.12 counterpart). The paint
|
||||
(KeyBindings_PaintBindRow / _PaintHeaderRow) shows one mode's furniture and hides the
|
||||
other's. -->
|
||||
<!-- A list row, painted as a binding (name, two capsules) or as a section header: one toggle
|
||||
in 1.12's QuestLogTitleButton dress, the 16x16 plus/minus square and gold label, since
|
||||
the era's expand arrows are not in 1.12. Both are 25 tall, the era's one row height
|
||||
(SettingsKeybindingSectionInitializer:GetExtent). -->
|
||||
<Frame name="KeyBindingRowTemplate" virtual="true">
|
||||
<Size><AbsDimension x="560" y="25"/></Size>
|
||||
<Layers>
|
||||
@@ -141,19 +84,17 @@
|
||||
</Frame>
|
||||
|
||||
<Script><![CDATA[
|
||||
-- ── Strings ── the 1.12 GlobalStrings globals, each with the raw fallback (a headless
|
||||
-- harness without GlobalStrings still works; the era's own `_G[x] or x` posture).
|
||||
-- ── Strings ── a 1.12 GlobalStrings global, or the fallback for a harness without them.
|
||||
function KeyBindings_String(name, fallback)
|
||||
local s = getglobal(name)
|
||||
if s then return s end
|
||||
return fallback
|
||||
end
|
||||
|
||||
-- ── Key display ── the 1.12 GetBindingText law by hand: modifier prefixes stay the
|
||||
-- canonical uppercase tokens, the base key reads its KEY_* global (Spacebar, Up Arrow,
|
||||
-- Mouse Button 4...) else itself. "CTRL--" renders CTRL + the minus key. (The action
|
||||
-- bar's ABBREVIATED labels ride the stock GetBindingText (UIParent.lua);
|
||||
-- this page and the tooltips show the full form, both references' own split.)
|
||||
-- ── Key display ── 1.12's GetBindingText in full form, as the reference shows it here and
|
||||
-- in tooltips (the action bar's abbreviated form is the stock GetBindingText): modifier
|
||||
-- prefixes stay uppercase tokens, the base key reads its KEY_* global, else itself.
|
||||
-- "CTRL--" is CTRL plus the minus key.
|
||||
function KeyBindings_KeyText(key)
|
||||
if not key then
|
||||
return NORMAL_FONT_COLOR_CODE .. KeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
|
||||
@@ -178,10 +119,9 @@
|
||||
return prefix .. KeyBindings_String("KEY_" .. rest, rest)
|
||||
end
|
||||
|
||||
-- ── Page state ── no root frame anymore: the page's state lives here. `sections` is the
|
||||
-- era section model; `selected`/`keyID` the 1.12 selection; `searchPainted` the live
|
||||
-- search-result rows (rowName → binding index), set by OptionsFrame.xml's search display
|
||||
-- and repainted after a bind lands on one.
|
||||
-- ── Page state ── `sections` is the era section model, `selected`/`keyID` the 1.12
|
||||
-- selection, and `searchPainted` maps OptionsFrame.xml's live search rows (row name to
|
||||
-- binding index).
|
||||
KeyBindingsPage = { sections = {}, items = {} }
|
||||
|
||||
KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
|
||||
@@ -189,10 +129,9 @@
|
||||
KEYBINDINGS_ROW_HEIGHT = 25
|
||||
KEYBINDINGS_SEARCH_ROWS = 8
|
||||
|
||||
-- ── The section model (era CreateKeybindingInitializers, cut to the honest tree): walk
|
||||
-- GetBinding(i), group runs of one category token; order = first appearance, which IS
|
||||
-- 1.12's Bindings.xml order (the registry registers in it). Sections default COLLAPSED
|
||||
-- (the era Classic overrides pass no `expanded`); a rebuild keeps each token's state.
|
||||
-- ── Section model ── the era's CreateKeybindingInitializers: one section per category
|
||||
-- token in first-appearance order, which is 1.12's Bindings.xml order. Sections start
|
||||
-- collapsed (the era's Classic overrides pass no `expanded`); a rebuild keeps each state.
|
||||
function KeyBindings_BuildSections()
|
||||
local page = KeyBindingsPage
|
||||
local was = {}
|
||||
@@ -214,8 +153,8 @@
|
||||
KeyBindings_Flatten()
|
||||
end
|
||||
|
||||
-- The flattened visible list (era EvaluateVisibility): every header, then the expanded
|
||||
-- sections' rows. The faux scroll runs over this.
|
||||
-- The visible list the faux scroll runs over (the era's EvaluateVisibility): each header,
|
||||
-- then its rows when expanded.
|
||||
function KeyBindings_Flatten()
|
||||
local page = KeyBindingsPage
|
||||
page.items = {}
|
||||
@@ -229,8 +168,7 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- The expand/collapse click (era OnExpandedChanged; the sound is the checkbox kit's,
|
||||
-- the page's one click voice).
|
||||
-- A header click (the era's OnExpandedChanged), voiced with the checkbox sound.
|
||||
function KeyBindings_ToggleSection(n)
|
||||
local sec = KeyBindingsPage.sections[n]
|
||||
if not sec then return end
|
||||
@@ -240,8 +178,7 @@
|
||||
KeyBindingsPage_Update()
|
||||
end
|
||||
|
||||
-- Expand/collapse by section index without the click voice — the capture fixture uses
|
||||
-- it to open a section for the lens.
|
||||
-- Expands or collapses a section silently; the capture fixture opens sections with it.
|
||||
function KeyBindings_ExpandSection(n, on)
|
||||
local sec = KeyBindingsPage.sections[n]
|
||||
if not sec then return end
|
||||
@@ -249,9 +186,8 @@
|
||||
KeyBindings_Flatten()
|
||||
end
|
||||
|
||||
-- ── Painters ── one row, one mode. Shared by the list repaint and the search rows
|
||||
-- (which are the SAME template painted bind-mode — the era reflows live rows into its
|
||||
-- results, and these capsules run the full capture law in place).
|
||||
-- ── Painters ── one row in one mode, shared by the list and the search rows, whose
|
||||
-- capsules capture in place as the era's live results do.
|
||||
function KeyBindings_PaintBindRow(name, index)
|
||||
local command, _, key1, key2 = GetBinding(index)
|
||||
local b1 = getglobal(name .. "Key1Button")
|
||||
@@ -265,7 +201,6 @@
|
||||
local desc = getglobal(name .. "Description")
|
||||
desc:SetText(KeyBindings_String("BINDING_NAME_" .. command, command))
|
||||
desc:Show()
|
||||
-- The 1.12 selection highlight: the pressed capsule holds its glow.
|
||||
local page = KeyBindingsPage
|
||||
if page.selected == command and page.keyID == 1 then
|
||||
b1:LockHighlight()
|
||||
@@ -320,16 +255,14 @@
|
||||
end
|
||||
FauxScrollFrame_Update(scroll, table.getn(page.items),
|
||||
KEYBINDINGS_DISPLAYED, KEYBINDINGS_ROW_HEIGHT)
|
||||
-- The trough is OURS — the reference has no trough at all, so nothing in its kit shows
|
||||
-- one. The owner pairs it with the bar the reference's `_Update` just decided about
|
||||
-- (1860); `BenillaScrollBar_SetShown` used to do it and is off this path now.
|
||||
-- The trough is ours and the stock kit never shows one, so it follows the bar
|
||||
-- `FauxScrollFrame_Update` has just shown or hidden.
|
||||
BenillaScrollTrough_Follow(scroll:GetName())
|
||||
KeyBindings_UpdateUnbindKey()
|
||||
end
|
||||
|
||||
-- The page entry (BenillaOptionsFrame_SelectCategory's Keybindings arm): rebuild the tree —
|
||||
-- the table may have moved under us (a rebind, a set switch) — sync the checkbox to
|
||||
-- the live set, park the scroll at the top, repaint.
|
||||
-- The page entry (BenillaOptionsFrame_SelectCategory): rebuilds the sections, since a
|
||||
-- rebind or a set switch may have changed the table, then repaints from the top.
|
||||
function KeyBindingsPage_Refresh()
|
||||
KeyBindings_BuildSections()
|
||||
local box = getglobal("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck")
|
||||
@@ -346,9 +279,9 @@
|
||||
getglobal("BenillaOptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
|
||||
end
|
||||
|
||||
-- ── Selection + the capture arm ── selecting a capsule arms the HOST's capture seam
|
||||
-- (BenillaBindCapture): raw input is swallowed there and comes back canonical through
|
||||
-- KeyBindings_OnHostKey. Deselecting (either way) disarms.
|
||||
-- ── Selection ── selecting a capsule arms the host's capture (BenillaBindCapture), which
|
||||
-- swallows raw input and hands the canonical chord to KeyBindings_OnHostKey; deselecting
|
||||
-- disarms.
|
||||
function KeyBindings_SetSelected(command, keyID)
|
||||
KeyBindingsPage.selected = command
|
||||
KeyBindingsPage.keyID = keyID
|
||||
@@ -364,9 +297,8 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- Repaint whichever view currently shows binding rows: the page list, or the live
|
||||
-- search-result rows (a bind landed from one must relabel it — and its victim, which
|
||||
-- may sit on another visible row).
|
||||
-- Repaints whichever view shows binding rows, the list or every search row, since a bind
|
||||
-- from one search row can unbind a victim shown on another.
|
||||
function KeyBindings_RepaintVisible()
|
||||
if KeyBindingsPage.searchPainted then
|
||||
for name, index in pairs(KeyBindingsPage.searchPainted) do
|
||||
@@ -379,10 +311,9 @@
|
||||
end
|
||||
|
||||
function KeyBindingButton_OnClick(button)
|
||||
-- The 1.12 law: while something is selected, only LEFT/RIGHT on a capsule
|
||||
-- re-select or deselect — a middle/4/5 press is a BIND, and the host's armed
|
||||
-- capture seam (which runs after this click) takes it, so this handler must not
|
||||
-- touch the selection for those buttons.
|
||||
-- As 1.12: while armed, only a left or right click reselects or deselects. A middle,
|
||||
-- 4 or 5 press is a bind that the armed capture takes after this click, so it must
|
||||
-- leave the selection alone.
|
||||
local page = KeyBindingsPage
|
||||
if page.selected then
|
||||
if button ~= "LeftButton" and button ~= "RightButton" then
|
||||
@@ -406,15 +337,14 @@
|
||||
KeyBindings_RepaintVisible()
|
||||
end
|
||||
|
||||
-- The live-commit half of every mutation (1008's commit law — the header block): the
|
||||
-- table already moved; persist the set it belongs to.
|
||||
-- The live commit: saves the set a bind, unbind or reset has just changed.
|
||||
function KeyBindings_Commit()
|
||||
SaveBindings(GetCurrentBindingSet())
|
||||
end
|
||||
|
||||
-- The host's capture callback: `chord` is canonical (ALT-CTRL-SHIFT-TOKEN). The body is
|
||||
-- 1.12's OnKeyDown selected-branch verbatim: steal message, clear the command's keys,
|
||||
-- rebind in slot order so the pressed slot takes the new chord — then the live commit.
|
||||
-- The host's capture callback, `chord` canonical (ALT-CTRL-SHIFT-TOKEN): 1.12's OnKeyDown
|
||||
-- armed branch, then the live commit. A stolen chord names its victim in red only when it
|
||||
-- was the victim's last key; the command's keys rebind in slot order.
|
||||
function KeyBindings_OnHostKey(chord)
|
||||
local page = KeyBindingsPage
|
||||
if not page.selected then
|
||||
@@ -449,11 +379,9 @@
|
||||
KeyBindings_RepaintVisible()
|
||||
end
|
||||
|
||||
-- 1.12 KeyBindingFrame_SetBinding, kept verbatim: a refused bind restores the slot's old
|
||||
-- key and says why. The reference wrote this arm with the wheel in mind and named its
|
||||
-- string for it, but the engine refuses only a key string that is not a key
|
||||
-- (decision 1295), so this fires for a chord the capture seam could not name — never for
|
||||
-- a wheel notch. The message stays 1.12's; ours is not the place to correct Blizzard.
|
||||
-- 1.12's KeyBindingFrame_SetBinding: a refused bind restores the slot's old key. SetBinding
|
||||
-- refuses only a string that is not a key, so this fires for a chord the capture could not
|
||||
-- name, never for the wheel; the message stays 1.12's wheel text.
|
||||
function KeyBindings_SetOrRestore(key, command, oldKey)
|
||||
if not SetBinding(key, command) then
|
||||
if oldKey then SetBinding(oldKey, command) end
|
||||
@@ -463,8 +391,8 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- The Unbind Key button (the Container header band, OptionsFrame.xml): 1.12's body —
|
||||
-- the selected slot empties, the other slides into slot order — plus the live commit.
|
||||
-- The Unbind Key button (OptionsFrame.xml): 1.12's body, which empties the selected slot
|
||||
-- and slides the other into slot order, then the live commit.
|
||||
function KeyBindings_Unbind()
|
||||
local page = KeyBindingsPage
|
||||
local key1, key2 = GetBindingKey(page.selected)
|
||||
@@ -481,14 +409,12 @@
|
||||
KeyBindings_RepaintVisible()
|
||||
end
|
||||
|
||||
-- ── The character-specific set (1.12's three-set model under the era checkbox row) ──
|
||||
-- CHECK: switch to the character set and save it at once — it starts as a copy of what
|
||||
-- you had (LoadBindings(2) falls back to the account snapshot; SaveBindings(2) makes it
|
||||
-- exist, which IS the checkbox's persisted truth). UNCHECK: destructive — the era
|
||||
-- confirm-on-uncheck-only law; the box springs back until the popup decides. The accept
|
||||
-- order is load-then-save: LoadBindings(1) puts the account set live FIRST, so
|
||||
-- SaveBindings(1) persists it unchanged while dropping the character set (the confirmed
|
||||
-- permanent delete — saving live-as-account here would smuggle the character binds in).
|
||||
-- ── Character-specific set ── 1.12's binding sets under the era's checkbox. Checking
|
||||
-- saves set 2 at once, a copy of the current binds (LoadBindings(2) falls back to the
|
||||
-- account set), and that saved set is what the checkbox reads. Unchecking deletes it, so
|
||||
-- it confirms (the era confirms only on uncheck) and the box stays checked meanwhile.
|
||||
-- The accept loads set 1 before saving it; saving first would write the character binds
|
||||
-- into the account set.
|
||||
function KeyBindings_CharacterToggle(box)
|
||||
PlaySound("igMainMenuOptionCheckBoxOn")
|
||||
if box:GetChecked() then
|
||||
@@ -516,9 +442,8 @@
|
||||
KeyBindingsPage_Refresh()
|
||||
end,
|
||||
}
|
||||
-- Reset To Default — the page's Defaults button (era: Defaults confirms; the string is
|
||||
-- ours, 0997's note: the era's lives in its client binary, 1.12 has none). The reset
|
||||
-- lands in the CURRENT set and commits live like every other mutation.
|
||||
-- Reset To Default, the page's Defaults button: it confirms, as the era's does, with our
|
||||
-- own string since 1.12 has none, and resets the current set with a live commit.
|
||||
StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = {
|
||||
text = "Are you sure you want to reset all keybindings to their defaults?",
|
||||
button1 = KeyBindings_String("OKAY", "Okay"),
|
||||
@@ -533,12 +458,11 @@
|
||||
end,
|
||||
}
|
||||
|
||||
-- ── Search (the era redirect law) ── every binding's search tag is its display name
|
||||
-- uppercased (era AddSearchTags(GetBindingName(action))); matches score by the shared
|
||||
-- law (OptionsSearch_Score) and surface under the Keybindings group head, whose click
|
||||
-- jumps to this page (era Blizzard_SettingsPanel.lua l.578: the fake category's results
|
||||
-- carry `redirectCategory`). The matched rows are LIVE (era reflows real initializers);
|
||||
-- OptionsFrame.xml paints them onto its static KeybindSearch row pool.
|
||||
-- ── Search ── a binding's tag is its display name uppercased (the era's
|
||||
-- AddSearchTags(GetBindingName(action))); matches list under the Keybindings group head,
|
||||
-- which opens this page (the era's `redirectCategory`, Blizzard_SettingsPanel.lua l.578).
|
||||
-- OptionsFrame.xml paints them as live rows. Deviation: a static pool of
|
||||
-- KEYBINDINGS_SEARCH_ROWS rows, where the era's results scroll.
|
||||
function KeyBindings_SearchMatches(words)
|
||||
local out = {}
|
||||
for i = 1, GetNumBindings() do
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,68 +1,31 @@
|
||||
<Ui>
|
||||
<!--
|
||||
benilla's **script error log** (decision 1495) — the window B293 asked for, and it is
|
||||
entirely OURS: 1.12 has no counterpart, so every name here wears the `Benilla` prefix (1254's
|
||||
rule, `tests::reference_surface`'s gate).
|
||||
|
||||
## Why it exists beside `ScriptErrors`
|
||||
|
||||
`BasicControls.xml`'s red `ScriptErrors` dialog is the reference's whole answer to a Lua
|
||||
fault, transcribed and installed by 1305 — and it stays exactly as it is. It shows **one**
|
||||
message, because `_ERRORMESSAGE` guards on `ScriptErrors:IsVisible()`, and it remembers
|
||||
nothing. Two reporters said so within days of it shipping: Goudy, *minutes after
|
||||
screenshotting that dialog working*, *"some kind of lua error frame probably needs to be
|
||||
implemented, as there are a lot of addons that still doesn't work"*; nazriel_0, *"probably we
|
||||
should start using something like Bug Grabber to report those stack traces better"*.
|
||||
|
||||
So: the dialog is the interrupt, this is the log. Additive, no divergence — the reference
|
||||
simply never had this instrument.
|
||||
|
||||
## Shape
|
||||
|
||||
`CraftFrame.xml`'s shape, which is the house idiom for "windowed panel, faux-scrolled row
|
||||
list, detail pane that repaints on selection": 13 fixed 16px rows over a
|
||||
`FauxScrollFrameTemplate`, and a **real** `<ScrollFrame>` detail pane below (the real kit,
|
||||
not the faux one — a message is a `mlua` error string with its `stack traceback:` tail
|
||||
attached, so the detail is genuinely variable-height content, which is what the real kit is
|
||||
for; `GossipFrame.xml`'s greeting pane is the same wiring).
|
||||
|
||||
The window registers itself in `UIPanelWindows` (below; the table is the stock UIParent.lua's
|
||||
since 1988), which is what seats it in the
|
||||
left slot and gets it closed by `CloseAllWindows()` — i.e. by ESC — with no code here. Our
|
||||
own toplevels deliberately stay OUT of `UISpecialFrames`: that table is the addons' (0544's
|
||||
note, 1206).
|
||||
|
||||
## Reading the model
|
||||
|
||||
Three engine reads, all from the retained log on the model
|
||||
(`benilla_ui::script::diagnostics`): `BenillaGetNumScriptErrors()` →
|
||||
`retained, totalEverRecorded`; `BenillaGetScriptErrorInfo(i)` → `seq, kind, message, count`
|
||||
(1-based, oldest first, nil past the end); `BenillaClearScriptErrors()`. There is no per-frame
|
||||
feed and nothing to keep in step — the window polls the log it is a view of.
|
||||
Benilla's script error log. 1.12 has no such window, so every name here carries the
|
||||
`Benilla` prefix (`tests::reference_surface` checks it). It sits beside the stock
|
||||
`ScriptErrors` dialog, which shows one message and keeps none: the dialog interrupts,
|
||||
this window keeps the log.
|
||||
Shaped like the reference's CraftFrame: 13 fixed 16px rows over the stock faux-scroll kit
|
||||
and a detail pane below. It registers in `UIPanelWindows`, which seats it in the left slot
|
||||
and closes it on ESC, and stays out of `UISpecialFrames`, which belongs to addons.
|
||||
The engine's log API: `BenillaGetNumScriptErrors()` returns retained and total-ever
|
||||
counts; `BenillaGetScriptErrorInfo(i)` returns `seq, kind, message, count` (1-based,
|
||||
oldest first, nil past the end); `BenillaClearScriptErrors()`. The window polls it.
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
-- The slot-manager row (decision 1495): a left-area window one tier above every stock
|
||||
-- panel's pushable, so opening a game window pushes the log aside rather than the reverse.
|
||||
-- A left-slot window with a pushable above every stock panel's, so a game window opening
|
||||
-- pushes the log aside.
|
||||
UIPanelWindows["BenillaScriptLogFrame"] = { area = "left", pushable = 9, whileDead = 1 }
|
||||
|
||||
BENILLA_SCRIPTLOG_ROWS = 13
|
||||
BENILLA_SCRIPTLOG_ROW_HEIGHT = 16
|
||||
-- How much of a message a ROW shows. The full text is the detail pane's job; a row is an
|
||||
-- index, and a row that wraps would break the fixed-height list the faux kit requires.
|
||||
-- How much of a message a row shows; a wrapped row would break the faux kit's fixed rows.
|
||||
BENILLA_SCRIPTLOG_ROW_CHARS = 52
|
||||
|
||||
-- Which row is selected (a `seq`, not an index — indices shift when the cap evicts, and a
|
||||
-- selection that silently slides onto a different error is worse than one that goes away).
|
||||
-- The selected row's `seq`, not its index: indices shift when the cap evicts.
|
||||
BenillaScriptLogSelected = nil
|
||||
|
||||
-- The first line of a message, trimmed to a row's width. A traceback's later lines are
|
||||
-- indented continuations; on one row they read as noise.
|
||||
-- `strsub` is BYTE-indexed (Lua 5.0 strings are byte strings, and benilla keeps them that
|
||||
-- way — decision 1193), so a cut that lands inside a multi-byte character leaves half of
|
||||
-- one. Every message here is UTF-8 and ours are full of em dashes, so this was not
|
||||
-- theoretical: the log window's own repaint raised on the first warning it was ever asked
|
||||
-- to show (2135/2138). Back the cut up over any continuation byte (`10xxxxxx`, i.e.
|
||||
-- 0x80..0xBF) so it lands on a character boundary.
|
||||
-- A message's first line, trimmed to a row. `strsub` counts bytes and messages are UTF-8,
|
||||
-- so the cut backs up over continuation bytes (0x80..0xBF) onto a character boundary.
|
||||
function BenillaScriptLog_RowText(message)
|
||||
local cut = strfind(message, "\n", 1, 1)
|
||||
local head = message
|
||||
@@ -79,13 +42,10 @@
|
||||
return head
|
||||
end
|
||||
|
||||
-- The three kinds, coloured by CONSEQUENCE — how much of the addon is broken:
|
||||
-- `load` red an addon is not running at all (the thing a player can act on)
|
||||
-- Coloured by how much of the addon is broken:
|
||||
-- `load` red the addon is not running at all
|
||||
-- `error` amber code ran and raised; the addon is otherwise up
|
||||
-- `warn` grey a call was accepted and quietly did not do what it said (2135)
|
||||
-- Grey rather than a fourth bright colour because a warning is the least urgent of the
|
||||
-- three and there are more of them: colour has to keep ranking the list, not just label
|
||||
-- it. Colours come from the shipped tables so this window matches the rest of the UI.
|
||||
-- `warn` grey a call was accepted and quietly did not do what it said
|
||||
function BenillaScriptLog_RowColor(kind)
|
||||
if kind == "load" then return 1.0, 0.3, 0.3 end
|
||||
if kind == "warn" then return 0.65, 0.65, 0.65 end
|
||||
@@ -95,12 +55,8 @@
|
||||
function BenillaScriptLog_Update()
|
||||
local shown, total = BenillaGetNumScriptErrors()
|
||||
|
||||
-- The summary line carries the one number a bug report needs and the window cannot
|
||||
-- otherwise show: how many were DROPPED. `total` counts distinct failures ever
|
||||
-- recorded; `shown` is what survived the cap.
|
||||
-- "problems", not "errors": the log carries three kinds now and two of them are not
|
||||
-- errors (2135). The window is still `/errors` and still opens on the same key —
|
||||
-- what changed is that a count of 7 stopped meaning "7 things raised".
|
||||
-- `total` counts distinct failures ever recorded, `shown` what the cap kept. The
|
||||
-- summary says "problems" because two of the three kinds are not errors.
|
||||
local summary
|
||||
if total == 0 then
|
||||
summary = "No problems this session."
|
||||
@@ -126,9 +82,7 @@
|
||||
if seq then
|
||||
button.seq = seq
|
||||
button.message = message
|
||||
-- The count rides the index, not the text: `#12 x1113` says "this is one
|
||||
-- failure that happened 1,113 times", which is the whole reason the log
|
||||
-- deduplicates instead of listing 1,113 rows.
|
||||
-- `#12 x1113`: one deduplicated failure that happened 1,113 times.
|
||||
local label = "#" .. seq
|
||||
if count > 1 then label = label .. " x" .. count end
|
||||
button.index:SetText(label)
|
||||
@@ -143,9 +97,8 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- The moving highlight, seated onto the selected row only when that row is on screen —
|
||||
-- scrolling the selection away hides the glow rather than stranding it (CraftFrame's
|
||||
-- own behaviour).
|
||||
-- The highlight follows the selected row and hides when it scrolls away, as the
|
||||
-- reference's CraftFrame does.
|
||||
local highlight = getglobal("BenillaScriptLogHighlightFrame")
|
||||
if selectedRow then
|
||||
highlight:SetPoint("TOPLEFT", selectedRow:GetName(), "TOPLEFT", 0, 0)
|
||||
@@ -157,8 +110,8 @@
|
||||
BenillaScriptLog_UpdateDetail()
|
||||
end
|
||||
|
||||
-- The detail pane: the FULL message of the selected row, traceback and all. Selection is by
|
||||
-- `seq`, so this walks the log rather than indexing it.
|
||||
-- The selected message in full, traceback and all; selection is a `seq`, so this walks
|
||||
-- the log rather than indexing it.
|
||||
function BenillaScriptLog_UpdateDetail()
|
||||
local text = ""
|
||||
if BenillaScriptLogSelected then
|
||||
@@ -177,18 +130,14 @@
|
||||
getglobal("BenillaScriptLogDetailText"):SetText(text)
|
||||
end
|
||||
|
||||
-- Frame-late by nature (the shared kit's own note): text rects resolve after Lua runs, so
|
||||
-- the child is re-measured from OnUpdate until it stops changing, after which this is a
|
||||
-- no-op compare per frame. OnUpdate only fires on visible frames, so a closed window costs
|
||||
-- nothing.
|
||||
-- Text rects resolve after Lua runs, so the detail child is re-measured every frame; once
|
||||
-- it settles that is one compare, and OnUpdate fires only while the window is shown.
|
||||
function BenillaScriptLogFrame_OnUpdate()
|
||||
BenillaScroll_ResizeChild("BenillaScriptLogDetailScroll", { "BenillaScriptLogDetailText" })
|
||||
end
|
||||
|
||||
function BenillaScriptLogRow_OnClick(button)
|
||||
if not button.seq then return end
|
||||
-- Clicking the selected row deselects: the detail pane is a drawer, and a list with no
|
||||
-- way back to "nothing selected" is a small trap.
|
||||
if BenillaScriptLogSelected == button.seq then
|
||||
BenillaScriptLogSelected = nil
|
||||
else
|
||||
@@ -198,20 +147,17 @@
|
||||
end
|
||||
|
||||
function BenillaScriptLogFrame_OnLoad(self)
|
||||
-- The ring's icon, through the portrait verb rather than left at its `file=` — see the
|
||||
-- texture's own note. MailFrame.xml's open-letter icon is the same fix.
|
||||
-- Masks the square icon to the portrait ring.
|
||||
SetPortraitToTexture("BenillaScriptLogFramePortrait",
|
||||
"Interface\\Icons\\INV_Misc_Note_01")
|
||||
end
|
||||
|
||||
-- The shared helper steps the faux frame's bar, whose OnValueChanged runs the kit's
|
||||
-- offset handler and thence `updateFunc` — so the repaint is wired once, at OnLoad, and
|
||||
-- not repeated here.
|
||||
-- Stepping the bar repaints through the list frame's OnVerticalScroll, so no repaint here.
|
||||
function BenillaScriptLogFrame_OnMouseWheel(delta)
|
||||
BenillaFauxScrollFrame_OnMouseWheel(getglobal("BenillaScriptLogListScrollFrame"), delta)
|
||||
end
|
||||
|
||||
-- ── The three entry points the client and the player reach this by ────────────────────
|
||||
-- ── Entry points ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function BenillaScriptLog_Toggle()
|
||||
local frame = getglobal("BenillaScriptLogFrame")
|
||||
@@ -222,10 +168,8 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- `/errors clear`. Forgets the rows, keeps the numbering (the engine's own rule — a number
|
||||
-- must never name two different failures across a screenshot). Prints a confirmation
|
||||
-- because a command whose whole effect is "a window you may not have open is now empty"
|
||||
-- is otherwise indistinguishable from a typo.
|
||||
-- `/errors clear`: forgets the rows but keeps the numbering, so a number never names two
|
||||
-- failures. It confirms in chat because the window may be closed.
|
||||
function BenillaScriptLog_Clear()
|
||||
BenillaClearScriptErrors()
|
||||
BenillaScriptLogSelected = nil
|
||||
@@ -236,9 +180,8 @@
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<!-- One list row: the `#N xCount` index column, then the message head. Fixed columns, 1.12's
|
||||
own list idiom — not a width limitation: a FontString with a zero-authored axis takes its
|
||||
measured box into the resolve (`benilla-ui` `script/layout.rs`, decision 1664). -->
|
||||
<!-- One list row: the `#N xCount` index column, then the message head, in fixed columns as
|
||||
1.12's lists are. -->
|
||||
<Button name="BenillaScriptLogRowTemplate" virtual="true" hidden="true">
|
||||
<Size><AbsDimension x="293" y="16"/></Size>
|
||||
<Layers>
|
||||
@@ -268,18 +211,15 @@
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<!-- The same square ITEM icon the mail window's ring holds, and it needs the same
|
||||
portrait verb for the same reason (`SetPortraitToTexture` in OnLoad below):
|
||||
`file=` alone draws it unmasked and its corners show through the ring's
|
||||
transparent ones. -->
|
||||
<!-- A square item icon: OnLoad masks it with `SetPortraitToTexture`, since `file=`
|
||||
alone shows its corners through the ring. -->
|
||||
<Texture name="BenillaScriptLogFramePortrait" file="Interface\Icons\INV_Misc_Note_01">
|
||||
<Size><AbsDimension x="60" y="60"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<!-- The four quadrant slabs. The ClassTrainer family, all four, because it is the one
|
||||
UNIFORM set in the 1.12 art (CraftFrame's own note) and this window belongs to no
|
||||
profession — borrowing a themed half would make it look like a window it isn't. -->
|
||||
<!-- The ClassTrainer quadrants, the one uniform set in the 1.12 art; this window
|
||||
belongs to no profession. -->
|
||||
<Layer level="BORDER">
|
||||
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopLeft"><Size><AbsDimension x="256" y="256"/></Size><Anchors><Anchor point="TOPLEFT"/></Anchors></Texture>
|
||||
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopRight"><Size><AbsDimension x="128" y="256"/></Size><Anchors><Anchor point="TOPRIGHT"/></Anchors></Texture>
|
||||
@@ -288,22 +228,18 @@
|
||||
<FontString name="BenillaScriptLogTitle" inherits="GameFontNormal" justifyH="CENTER" text="Script Errors">
|
||||
<Anchors><Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
<!-- The summary line, and the ONLY place the dropped-row count can be told. -->
|
||||
<!-- The summary line, the only place the dropped-row count shows. -->
|
||||
<FontString name="BenillaScriptLogSummary" inherits="GameFontHighlightSmall" justifyH="LEFT" text="">
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="26" y="-76"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The top-right X: the chain's own `UIPanelCloseButton` (seated at benilla.toc's
|
||||
`Interface\FrameXML\UIPanelTemplates.xml`, 757 lines above this file). Its
|
||||
32x32, its three minimize-button state textures and its
|
||||
`HideUIPanel(this:GetParent())` are byte-for-byte what stood here authored. -->
|
||||
<Button name="BenillaScriptLogCloseButton" inherits="UIPanelCloseButton">
|
||||
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-29" y="-8"/></Offset></Anchor></Anchors>
|
||||
</Button>
|
||||
|
||||
<!-- Declared BEFORE the rows so row text paints over the glow (the house z-tiebreak). -->
|
||||
<!-- Declared before the rows so their text draws over the glow. -->
|
||||
<Frame name="BenillaScriptLogHighlightFrame" hidden="true">
|
||||
<Size><AbsDimension x="293" y="16"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||||
@@ -335,16 +271,14 @@
|
||||
<Size><AbsDimension x="293" y="208"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<!-- The reference's kit writes `frame.offset` ONLY from here; its
|
||||
`updateFunc` field does not exist, so without this the bar moves and
|
||||
the list does not follow (1868). -->
|
||||
<!-- The stock kit sets the offset only from here and has no `updateFunc`, so
|
||||
the repaint is wired here too. -->
|
||||
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_SCRIPTLOG_ROW_HEIGHT, BenillaScriptLog_Update)</OnVerticalScroll>
|
||||
</Scripts>
|
||||
</ScrollFrame>
|
||||
|
||||
<!-- The detail pane: the REAL scroll kit, because a message carries its `stack
|
||||
traceback:` tail and is genuinely variable-height. GossipFrame's greeting pane is
|
||||
the same wiring. -->
|
||||
<!-- The detail pane, on the real-scroll kit: a message with its `stack traceback:` tail
|
||||
has variable height. -->
|
||||
<ScrollFrame name="BenillaScriptLogDetailScroll">
|
||||
<Size><AbsDimension x="293" y="128"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-314"/></Offset></Anchor></Anchors>
|
||||
@@ -367,8 +301,7 @@
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<!-- 270 wide so the engine wraps the traceback rather than clipping
|
||||
it; the pane pans over whatever height that comes to. -->
|
||||
<!-- 270 wide so the traceback wraps rather than clips. -->
|
||||
<FontString name="BenillaScriptLogDetailText" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP" text="">
|
||||
<Size><AbsDimension x="270" y="0"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="6" y="-6"/></Offset></Anchor></Anchors>
|
||||
@@ -392,18 +325,16 @@
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaScriptLogFrame_OnLoad(self)</OnLoad>
|
||||
<!-- The list wheel lives on the WINDOW: the engine bubbles a spin up the parent chain
|
||||
(CraftFrame's own note). The detail pane has its own, which wins over its own rect. -->
|
||||
<!-- The list's wheel handler is on the window, since a spin bubbles up the parent
|
||||
chain; the detail pane's own handler wins over its rect. -->
|
||||
<OnMouseWheel>BenillaScriptLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
|
||||
<OnShow>BenillaScriptLog_Update()</OnShow>
|
||||
<OnUpdate>BenillaScriptLogFrame_OnUpdate()</OnUpdate>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<!-- benilla's own commands over the reference's SlashCmdList (decision 1948). The stock
|
||||
ChatEdit_ParseText walks SlashCmdList after its own built-ins, so each of benilla's commands
|
||||
(ui_chat::commands — the dev instruments and the three additions) hands its line to
|
||||
SubmitChatInput, where ui_chat::input's parser still owns it. This file loads after the
|
||||
reference's ChatFrame.xml, which is what declares the table. -->
|
||||
<!-- Benilla's slash commands in the stock SlashCmdList, which ChatEdit_ParseText walks after
|
||||
its built-ins; each hands its line to SubmitChatInput, where ui_chat::input parses it.
|
||||
This file loads after the stock ChatFrame.xml, which declares the table. -->
|
||||
<Script><![CDATA[
|
||||
local BENILLA_COMMANDS = {
|
||||
{ "CHATTEST", "/chattest" },
|
||||
|
||||
@@ -1,54 +1,30 @@
|
||||
<Ui>
|
||||
<!--
|
||||
benilla's reusable scroll kits — TWO of them, sharing one scrollbar, one pair of arrow
|
||||
helpers and one optional TROUGH (`BenillaScrollTroughTemplate`, at the foot: the recessed
|
||||
channel a bar rides in — 1.12 authors that art per window, so an owner declares one beside
|
||||
its bar and the kit shows and hides the two together).
|
||||
The **faux-scroll** kit (this header) re-binds a fixed set of rows to a moving data
|
||||
offset; the **real-scroll** kit (second half of the script block) pans and CLIPS a
|
||||
variable-height child through the engine's `<ScrollFrame>` (decision 0112) — the one a gossip
|
||||
menu of wrapped options needs, since its content height isn't known until it is laid out.
|
||||
Only their OnValueChanged differs, and an instance overrides the template's (the loader
|
||||
SetScripts every `<Scripts>` block in order, so the instance's handler wins).
|
||||
|
||||
The faux kit (decisions 0247/0250/0251): a draggable scroll bar over
|
||||
the `Slider` primitive (0250) plus the fixed-row "faux scroll" Lua layer — the reference's
|
||||
`FauxScrollFrame`, which never really scrolls a child: it re-binds a fixed set of row buttons to
|
||||
a moving data offset. First customer: the class trainer's skill-line list (TrainerFrame.xml).
|
||||
The quest log's chunk-local faux-scroll upvalue is a later migration onto this.
|
||||
|
||||
DESIGN — collapsed from the real client's three-part decomposition (0250: FauxScrollFrame =
|
||||
ScrollFrame + Slider + Lua). The real `FauxScrollFrameTemplate` routes the scrollbar's value
|
||||
through its parent `ScrollFrame` (`OnValueChanged → parent:SetVerticalScroll(v)`; the
|
||||
ScrollFrame's `OnVerticalScroll` then derives `offset = floor(v/itemHeight + 0.5)`). That
|
||||
ScrollFrame adds nothing to the value on its way through: 0251 believed it clamped to the
|
||||
scroll range and called the clamp a liability, and decision 2017 read the bytes — the
|
||||
reference's `SetVerticalScroll` stores what the bar hands it. So the relay is a pure
|
||||
pass-through, and benilla's faux frame is a plain `<Frame>` + a `Slider`: fewer moving parts
|
||||
and the identical on-screen result (a smooth, pixel-granularity thumb whose position maps
|
||||
1:1 to the scroll offset). See 0251 for the full rationale + the divergence from 0250's
|
||||
described (real-client) shape.
|
||||
|
||||
VALUE MODEL. The scrollbar's value is in PIXELS: `[0, (numItems − numToDisplay) * itemHeight]`,
|
||||
`valueStep = itemHeight`. The row offset is `floor(value / itemHeight + 0.5)` — sub-pixel
|
||||
rounding, so the thumb (drawn at the raw value fraction) and the list (drawn at the rounded
|
||||
offset) never visibly disagree. The frame carries `frame.offset` (the top visible row, 0-based)
|
||||
and `frame.updateFunc` (the owner's list-repaint, set by the owner's OnLoad).
|
||||
Benilla's scroll kits, sharing one `Slider` bar, its arrow helpers and an optional trough:
|
||||
a faux-scroll kit that re-binds a fixed set of rows to a moving data offset, and a
|
||||
real-scroll kit that pans and clips a `<ScrollFrame>` child whose height is known only after
|
||||
layout. A real-scroll bar overrides the template's OnValueChanged on its instance, which
|
||||
wins because the loader runs every `<Scripts>` block in order.
|
||||
Faux model: the value is pixels, `[0, (numItems - numToDisplay) * itemHeight]` in steps of
|
||||
`itemHeight`; the parent holds the 0-based top row in `frame.offset` and its repaint in
|
||||
`frame.updateFunc`, set by the owner's OnLoad.
|
||||
Deviation: the faux parent is a plain `<Frame>`, not the reference's ScrollFrame relay,
|
||||
which adds nothing because the reference's `SetVerticalScroll` stores the bar's value as is.
|
||||
`FauxScrollFrameTemplate` and the `FauxScrollFrame_*`, `ScrollFrame_*` and
|
||||
`ScrollingEdit_*` API are the stock `UIPanelTemplates` on the player's chain, which loads
|
||||
after this file, so its declarations win.
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
-- Step the bar one row (the slider's valueStep) — the sensible step for an item list (the
|
||||
-- generic ref scrollbar steps half its height; a discrete row list wants one). Silent by
|
||||
-- design: the ref plays UChatScrollButton only on the arrow *buttons'* OnClick, never on the
|
||||
-- wheel (ScrollFrameTemplate_OnMouseWheel is soundless). Both the wheel and the arrows call
|
||||
-- this, so the click lives on the arrow OnClick, keeping wheel-scroll silent.
|
||||
-- Steps the bar by its valueStep. Deviation: one row on a faux list, where the reference
|
||||
-- bar's arrows step half its height. Soundless: the reference plays UChatScrollButton on
|
||||
-- an arrow click, never on the wheel, so the arrows' OnClick plays it.
|
||||
function BenillaScrollBar_Step(scrollBar, dir)
|
||||
local step = scrollBar:GetValueStep()
|
||||
if step <= 0 then step = 1 end
|
||||
scrollBar:SetValue(scrollBar:GetValue() + dir * step)
|
||||
end
|
||||
|
||||
-- Grey the up arrow at the top and the down arrow at the bottom (ref
|
||||
-- ScrollFrame_OnScrollRangeChanged's arrow handling), from the slider's own value/range.
|
||||
-- Greys each arrow at its end of the range (reference: ScrollFrame_OnScrollRangeChanged).
|
||||
function BenillaScrollBar_UpdateArrows(scrollBar)
|
||||
local minV, maxV = scrollBar:GetMinMaxValues()
|
||||
local val = scrollBar:GetValue()
|
||||
@@ -58,29 +34,11 @@
|
||||
if val >= maxV then down:Disable() else down:Enable() end
|
||||
end
|
||||
|
||||
-- The scrollbar's OnValueChanged: derive the row offset from the (pixel) value and, only when
|
||||
-- it actually moved, repaint the owner's list. The change-gate is load-bearing: the owner's
|
||||
-- repaint calls _Update below, which SetValue()s the thumb back to offset*itemHeight — a value
|
||||
-- that maps to the SAME offset, so this handler no-ops and the cycle stops after one hop (the
|
||||
-- Slider's own SetValue change-gate stops the value side, decision 0250).
|
||||
--
|
||||
-- THE SECOND HALF IS FOR ADDONS, and it is the reference's own wiring. The ref's
|
||||
-- `UIPanelScrollBarTemplate` routes its value through the ScrollFrame it rides
|
||||
-- (`<OnValueChanged>this:GetParent():SetVerticalScroll(arg1)</OnValueChanged>`,
|
||||
-- ref-UIPanelTemplates.xml l.156-158), and that frame's `<OnVerticalScroll>` is where an
|
||||
-- addon puts `FauxScrollFrame_OnVerticalScroll(itemHeight, MyUpdate)`. Our OWN faux frames
|
||||
-- are plain `<Frame>`s (the 0251 divergence in the header above), so they have no
|
||||
-- SetVerticalScroll and this line never fires for them — but an addon's is declared
|
||||
-- `<ScrollFrame ... inherits="FauxScrollFrameTemplate">`, and the instance's own tag wins
|
||||
-- over the template's on inherit (framexml.rs `merge`), so it really is one. Duck-typed for
|
||||
-- exactly that reason.
|
||||
--
|
||||
-- The engine's SetVerticalScroll takes the bar's value as given (decision 2017 — the
|
||||
-- reference's never reads the scroll range), so an addon frame's `<OnVerticalScroll>`
|
||||
-- fires with this very value and its `FauxScrollFrame_OnVerticalScroll` derives the offset
|
||||
-- from it, the reference's path end to end. The offset our own kit computes above is the
|
||||
-- same number by construction — it comes straight off the Slider, which is the whole point
|
||||
-- of 0251.
|
||||
-- The faux bar's OnValueChanged: the top row is the value rounded to the nearest row, so
|
||||
-- thumb and rows agree, and `updateFunc` runs only when that row changes, which also ends
|
||||
-- the loop when a repaint re-seats the thumb.
|
||||
-- A ScrollFrame parent also gets the value through SetVerticalScroll, as the reference's
|
||||
-- `UIPanelScrollBarTemplate` routes it (UIPanelTemplates.xml l.156-158).
|
||||
function BenillaFauxScrollBar_OnValueChanged(scrollBar, value)
|
||||
local frame = scrollBar:GetParent()
|
||||
local step = scrollBar:GetValueStep()
|
||||
@@ -94,27 +52,10 @@
|
||||
if frame.SetVerticalScroll then frame:SetVerticalScroll(value) end
|
||||
end
|
||||
|
||||
-- Seat a trough ON ITS BAR. THE SOCKETS, and why this is not a free choice: the channel art
|
||||
-- is not a plain tube — each end carries a 16-tall SOCKET that the arrow button drops into,
|
||||
-- bounded by the frame's outer cap and an inner crossbar. Measured texel-exact off
|
||||
-- UI-Character-ScrollBar (64x256):
|
||||
-- top piece (left column) : rows 0-3 CAP · rows 4-19 SOCKET · rows 20-21 crossbar
|
||||
-- bottom piece (right column): rows 85-86 crossbar · rows 87-102 SOCKET · rows 104-106 CAP
|
||||
-- Both pieces draw 1:1 anchored to their own end of the trough, and the arrow buttons sit 16
|
||||
-- outside the slider's ends — so a socket lands on its arrow only when the trough OVERHANGS
|
||||
-- the bar by 21 above and 20 below (4 rows of cap at the top against 3+1 at the bottom; that
|
||||
-- 1 is the whole asymmetry). Those are the ref's own numbers, reached from the other side:
|
||||
-- ReputationFrame hangs its trough at the scroll frame's TOPRIGHT (-2,+5) / BOTTOMRIGHT
|
||||
-- (-2,-4) against a bar seated (6,-16)/(6,+16) — 21, 20, and the 8-left hang
|
||||
-- (ref-ReputationFrame.xml l.573-599 against ref-UIPanelTemplates.xml l.166-181). Miss it by
|
||||
-- even 4 and the arrows ride OUT of their sockets onto the caps with a sliver of bare socket
|
||||
-- showing beside them, which is exactly what B224 reported.
|
||||
--
|
||||
-- Seated HERE, off the bar, rather than restated in each owner's XML: it is one law, and an
|
||||
-- owner made to re-derive it in offsets is an owner that can get it wrong — which is how
|
||||
-- B224 happened. So a trough carries NO anchors of its own; declare it before the bar (so it
|
||||
-- draws beneath) and name it `<barname>Trough`, and that is the whole contract. Lazy,
|
||||
-- because the bar is declared after the trough and does not exist at the trough's OnLoad.
|
||||
-- Seats a trough on its bar with the reference's ReputationFrame hang, 8 left, 21 above and
|
||||
-- 20 below (ReputationFrame.xml l.573-599 against UIPanelTemplates.xml l.166-181), which
|
||||
-- puts each art socket on its arrow button. Lazy, because the bar is declared after its
|
||||
-- trough and does not exist at the trough's OnLoad.
|
||||
function BenillaScrollTrough_Seat(trough, scrollBar)
|
||||
if trough.seated then return end
|
||||
trough.seated = true
|
||||
@@ -123,19 +64,9 @@
|
||||
trough:SetPoint("BOTTOMRIGHT", scrollBar, "BOTTOMRIGHT", 7, -20)
|
||||
end
|
||||
|
||||
-- Show/hide a bar TOGETHER WITH ITS TROUGH — the recessed channel it rides in, if the
|
||||
-- owner authored one beside it (a `<barname>Trough` frame on BenillaScrollTroughTemplate
|
||||
-- below). Every path that shows or hides a bar goes through here, so a window that fits
|
||||
-- shows neither and one that overflows shows both — and so a trough is seated on its bar
|
||||
-- before it can ever be visible, whichever way the first call goes.
|
||||
--
|
||||
-- Why the trough is OPT-IN rather than part of the bar template: 1.12 authors trough art
|
||||
-- PER WINDOW, never in the shared scroll kit — `UIPanelScrollBarTemplate` carries only the
|
||||
-- two arrow buttons and the knob, and the ref's FrameXML then hangs loose trough textures
|
||||
-- beside 29 individual bars, in two different skins (UI-Character-ScrollBar for the
|
||||
-- dialog/paperdoll family, UI-ClassTrainer-ScrollBar for the trainer family). The era's own
|
||||
-- MinimalScrollBar went the other way and made its Track part of the bar; this pair keeps
|
||||
-- the era's COUPLING (track lives and dies with the bar) on 1.12's per-window SKIN.
|
||||
-- Shows or hides a bar and its `<barname>Trough` together, seating the trough first; show
|
||||
-- and hide a bar only through here. The trough is opt-in because 1.12 draws trough art per
|
||||
-- window: `UIPanelScrollBarTemplate` carries only the arrows and the knob.
|
||||
function BenillaScrollBar_SetShown(scrollBar, shown)
|
||||
local trough = getglobal(scrollBar:GetName() .. "Trough")
|
||||
if trough then BenillaScrollTrough_Seat(trough, scrollBar) end
|
||||
@@ -148,17 +79,9 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- **Our trough, following the REFERENCE's bar.** `BenillaScrollBar_SetShown` above is the
|
||||
-- Benilla kit's own pairing and nothing in the reference's faux kit calls it — the
|
||||
-- reference has no trough at all, which is exactly why this template stays ours (1837's
|
||||
-- manifest note). Since the faux frames took the chain's `FauxScrollFrameTemplate` their
|
||||
-- bar is `UIPanelScrollBarTemplate`, so an owner that wants its trough asks for it here,
|
||||
-- right after its `FauxScrollFrame_Update`. Seating is idempotent and the visibility
|
||||
-- simply mirrors the bar's, which the reference's `_Update` has already decided by
|
||||
-- showing or hiding the scroll FRAME.
|
||||
--
|
||||
-- Decision 1860. Named after the bar rather than the frame because the trough is declared
|
||||
-- `<barname>Trough` — one law, stated once, exactly as BenillaScrollTrough_Seat wants it.
|
||||
-- The same for a stock `FauxScrollFrameTemplate` frame, whose bar is the reference's: seats
|
||||
-- `<frame>ScrollBarTrough` and mirrors the frame's shown state. Call it after
|
||||
-- `FauxScrollFrame_Update`, which shows or hides the frame.
|
||||
function BenillaScrollTrough_Follow(scrollFrameName)
|
||||
local sf = getglobal(scrollFrameName)
|
||||
if not sf then return end
|
||||
@@ -170,45 +93,30 @@
|
||||
if sf:IsShown() then trough:Show() else trough:Hide() end
|
||||
end
|
||||
|
||||
-- A wheel spin over the list → one row (WoW convention: +1 up, −1 down). Called from the
|
||||
-- owner window's OnMouseWheel (the engine bubbles the spin up the parent chain, so the handler
|
||||
-- lives on the window, not a child — TrainerFrame.xml's own note).
|
||||
--
|
||||
-- KEEPS the `Benilla` prefix on purpose: the reference has no faux-scroll wheel function to
|
||||
-- take the name of. Its `ScrollFrameTemplate_OnMouseWheel` is the REAL-scroll one and moves
|
||||
-- by half the bar's height; its faux owners each write a wheel body of their own inline.
|
||||
-- One row per wheel notch (+1 up) on a faux list, called from the owner window's
|
||||
-- OnMouseWheel since the spin bubbles up the parent chain. Benilla-named: the reference has
|
||||
-- no faux wheel function (its `ScrollFrameTemplate_OnMouseWheel` moves half the bar).
|
||||
function BenillaFauxScrollFrame_OnMouseWheel(frame, delta)
|
||||
local scrollBar = getglobal(frame:GetName() .. "ScrollBar")
|
||||
BenillaScrollBar_Step(scrollBar, -delta)
|
||||
end
|
||||
|
||||
-- ══════════════════════════ The REAL-scroll kit ══════════════════════════════════════════
|
||||
--
|
||||
-- The other half of the file: a true `<ScrollFrame>` (engine mechanism, decision 0112) that
|
||||
-- pans and CLIPS a variable-height child, as against the faux kit above (fixed rows re-bound
|
||||
-- to a data offset). Windows whose content height is unknown until it is laid out — a gossip
|
||||
-- menu of wrapped options, a quest's body text — need this one; a uniform list wants the faux.
|
||||
--
|
||||
-- SHAPE, from the reference `UIPanelScrollFrameTemplate` (Interface\FrameXML\
|
||||
-- UIPanelTemplates.xml l.166-211, extracted via mpqx): the scrollbar is a SLIDER that rides
|
||||
-- its ScrollFrame as a child, so `bar:GetParent()` IS the frame it scrolls — no name
|
||||
-- derivation, and `$parentBar` gives SyncBar its handle. The ref's OnVerticalScroll drives
|
||||
-- the bar's value and greys the two arrows at the ends; ours does the same through the
|
||||
-- shared arrow helper above.
|
||||
BENILLA_SCROLL_STEP = 20 -- px per arrow click / wheel notch
|
||||
BENILLA_SCROLL_PAD = 20 -- breathing room under the last element in the child
|
||||
-- ══════════════════════════ The real-scroll kit ══════════════════════════════════════════
|
||||
-- Shaped on the reference `UIPanelScrollFrameTemplate` (UIPanelTemplates.xml l.166-211):
|
||||
-- the bar is a `Slider` child, `$parentBar`, so `bar:GetParent()` is the frame it pans.
|
||||
BENILLA_SCROLL_STEP = 20 -- px per arrow click or wheel notch
|
||||
BENILLA_SCROLL_PAD = 20 -- px of room below the child's last element
|
||||
|
||||
-- The bar moved (drag, arrow, or SyncBar): pan the frame it rides. The loop this could form
|
||||
-- (bar → scroll → OnVerticalScroll → SyncBar → bar) dead-ends in one hop — SyncBar writes the
|
||||
-- value the bar already holds, and the Slider's SetValue change-gate (decision 0250) drops it.
|
||||
-- Pans the frame the bar rides. The loop back through OnVerticalScroll and SyncBar ends in
|
||||
-- one hop: SyncBar writes the value the bar already holds, which Slider:SetValue ignores.
|
||||
function BenillaScrollBar_OnValueChanged(bar, value)
|
||||
bar:GetParent():SetVerticalScroll(value)
|
||||
BenillaScrollBar_UpdateArrows(bar)
|
||||
end
|
||||
|
||||
-- Reconcile a ScrollFrame's bar to its live range (ref ScrollFrame_OnScrollRangeChanged):
|
||||
-- the bar spans [0, range] in pixels, seats at the current scroll, and hides entirely when
|
||||
-- the content fits. Wired to both OnScrollRangeChanged and OnVerticalScroll by the owner.
|
||||
-- Fits the bar to the frame's live range, [0, range] px, and hides it when the content fits
|
||||
-- (reference: ScrollFrame_OnScrollRangeChanged). Owners call it from OnScrollRangeChanged
|
||||
-- and OnVerticalScroll.
|
||||
function BenillaScroll_SyncBar(sf)
|
||||
local bar = getglobal(sf:GetName() .. "Bar")
|
||||
if not bar then return end
|
||||
@@ -220,10 +128,8 @@
|
||||
BenillaScrollBar_SetShown(bar, range > 0)
|
||||
end
|
||||
|
||||
-- Pan by `delta` px, clamped to [0, range] HERE: the engine's SetVerticalScroll stores
|
||||
-- what it is given (decision 2017 — the reference's never reads the range), and the
|
||||
-- reference's own wheel handler bounds itself the same way, through its bar's [min, max]
|
||||
-- (ScrollFrameTemplate_OnMouseWheel → scrollBar:SetValue).
|
||||
-- Pans by `delta` px, clamped here: SetVerticalScroll stores any value, as the reference's
|
||||
-- does; the reference's ScrollFrameTemplate_OnMouseWheel clamps through its bar's range.
|
||||
function BenillaScroll_Step(sf, delta)
|
||||
local target = sf:GetVerticalScroll() + delta
|
||||
local range = sf:GetVerticalScrollRange()
|
||||
@@ -233,13 +139,12 @@
|
||||
BenillaScroll_SyncBar(sf)
|
||||
end
|
||||
|
||||
-- WoW wheel convention: +1 per notch up, -1 down — up lifts the content.
|
||||
-- The wheel's delta is +1 per notch up, and up scrolls toward the top.
|
||||
function BenillaScroll_OnMouseWheel(sf, delta)
|
||||
BenillaScroll_Step(sf, -delta * BENILLA_SCROLL_STEP)
|
||||
end
|
||||
|
||||
-- A widget's resolved bottom edge for the content sum — nil when it isn't contributing
|
||||
-- (hidden frame / blank FontString / not yet resolved this frame).
|
||||
-- A widget's resolved bottom, or nil if hidden, a blank FontString, or not laid out yet.
|
||||
function BenillaScroll_WidgetBottom(w)
|
||||
if not w then return nil end
|
||||
if w.IsShown then
|
||||
@@ -251,19 +156,13 @@
|
||||
return w:GetBottom()
|
||||
end
|
||||
|
||||
-- Size a scroll child to its MEASURED content — the resolved bottom of the lowest visible
|
||||
-- widget, plus padding — instead of a fixed budget, and tell the frame (which fires
|
||||
-- OnScrollRangeChanged → the owner's SyncBar). Frame-late by nature: rects resolve after Lua
|
||||
-- runs, so owners re-run this from an OnUpdate until it stops changing. Returns true when it
|
||||
-- actually resized (only a real change writes: SetHeight invalidates layout, and an
|
||||
-- every-frame write would fight the resolver).
|
||||
-- Sizes the scroll child to its lowest visible widget plus BENILLA_SCROLL_PAD and updates
|
||||
-- the frame's range. Rects resolve after Lua runs, so owners re-run it from OnUpdate until
|
||||
-- it returns false; it writes only on a real change, since SetHeight invalidates layout.
|
||||
function BenillaScroll_ResizeChild(scrollName, widgets)
|
||||
local sf = getglobal(scrollName)
|
||||
-- ASK THE FRAME, don't guess a name. This used to be
|
||||
-- `getglobal(scrollName .. "Child")`, which only ever worked because every caller wore
|
||||
-- a private name in that exact shape — the reference's own convention is
|
||||
-- `<X>ScrollFrame` / `<X>ScrollChildFrame`, not a suffix, so the derivation broke the
|
||||
-- moment QuestFrame took the reference's names. A ScrollFrame knows its own child.
|
||||
-- The frame's own child, not a derived name: the reference pairs `<X>ScrollFrame` with
|
||||
-- `<X>ScrollChildFrame`.
|
||||
local child = sf:GetScrollChild()
|
||||
if not child then return false end
|
||||
local top = child:GetTop()
|
||||
@@ -284,11 +183,9 @@
|
||||
]]></Script>
|
||||
|
||||
<!--
|
||||
The scroll bar: a `Slider` (0250) 16px wide, its up/down step buttons pinned above/below the
|
||||
track, and the knob thumb. The value is in pixels (see the header); the thumb draws at the
|
||||
value fraction along the track (engine-side, decision 0250 §4), draggable (§5). The step-button
|
||||
+ knob art is the stock 1.12 scrollbar kit, each texture cropped to its centred 16px (the
|
||||
`0.25..0.75` TexCoords the ref's `UIPanelScrollBarButton` applies).
|
||||
The bar: a 16px `Slider` with step buttons above and below the track, on the stock 1.12
|
||||
scrollbar art cropped to its centred 16px (the `0.25..0.75` TexCoords of the reference's
|
||||
`UIPanelScrollBarButton`).
|
||||
-->
|
||||
<Slider name="BenillaScrollBarTemplate" virtual="true">
|
||||
<Size><AbsDimension x="16" y="0"/></Size>
|
||||
@@ -322,39 +219,24 @@
|
||||
</Slider>
|
||||
|
||||
<!--
|
||||
The scroll bar's TROUGH — the recessed channel the bar rides in: the "background and
|
||||
border" a bare arrows+knob bar is missing. An owner declares one of these BESIDE its bar,
|
||||
named `<barname>Trough` and WITH NO ANCHORS — it seats itself on the bar the first time
|
||||
BenillaScrollBar_SetShown touches it (BenillaScrollTrough_Seat above, where the socket law
|
||||
and the ref citations live). Declare it BEFORE the bar so it draws beneath.
|
||||
|
||||
THE ART, and why it is sliced this way. `Interface\PaperDollInfoFrame\UI-Character-ScrollBar`
|
||||
is a 64x256 sheet holding TWO 31-wide channel pieces, measured texel-exact from the file —
|
||||
rails at x 2-5 and 25-30 with a 19-wide interior between them, and each piece bearing a
|
||||
16-tall SOCKET for its arrow button:
|
||||
· LEFT column (u 0 .. 31/64): rows 0-3 the solid TOP cap, 4-19 the up-arrow socket,
|
||||
20-21 the crossbar that closes it, then plain channel from row 22 to the bottom edge.
|
||||
· RIGHT column (u 33/64 .. 1): plain channel, then rows 85-86 the crossbar, 87-102 the
|
||||
down-arrow socket, 104-106 the solid BOTTOM cap (nothing below 107).
|
||||
The ref composes a trough from exactly two quads — the whole left column plus the right
|
||||
column's bottom 108 rows — which reaches 362 units and no further, and only works because
|
||||
its scroll frames are short enough for the two to OVERLAP. Ours must span a 563-tall page,
|
||||
so the same art is cut as a THREE-SLICE instead: the two socket ends at their natural
|
||||
heights and a plain-channel slice stretched between them. The middle is constant down its
|
||||
whole length, so stretching it is exact — there is no seam to find.
|
||||
|
||||
The black `Fill` is the ref's own trough backing, not an invention: ItemTextFrame draws a
|
||||
0.8-alpha black quad behind its channel art for exactly this (ref-ItemTextFrame.xml's
|
||||
`Middle`). It sits in BACKGROUND and the channel art in BORDER — two layers, because within
|
||||
ONE layer the client re-sorts overlapping quads by texture handle and their order is not
|
||||
ours to depend on (order.rs). The bar's own furniture stays clear above both: button state
|
||||
textures are ARTWORK, the knob is OVERLAY.
|
||||
A bar's trough, the recessed channel it rides in; the reference's scroll kit has none.
|
||||
Declare it before its bar, so it draws beneath, named `<barname>Trough` and with no
|
||||
anchors: BenillaScrollTrough_Seat seats it on the bar.
|
||||
`UI-Character-ScrollBar` (64x256) holds two 31-wide channel pieces, the left column
|
||||
(u 0 .. 31/64) capped at the top and the right (u 33/64 .. 1) at the bottom, each with a
|
||||
16-tall socket for its arrow button: rows 4-19 at the top, rows 87-102 at the bottom.
|
||||
Deviation: cut as a three-slice (both socket ends at natural height, the plain channel
|
||||
stretched between) where the reference overlaps two quads, since two quads reach only 362
|
||||
units and a page here is 563 tall.
|
||||
The black fill is the reference's trough backing (ItemTextFrame's 0.8-alpha `Middle`). It
|
||||
is in BACKGROUND and the art in BORDER because the client orders overlapping quads within
|
||||
one layer by texture handle; the bar's buttons (ARTWORK) and knob (OVERLAY) draw above both.
|
||||
-->
|
||||
<Frame name="BenillaScrollTroughTemplate" virtual="true" hidden="true">
|
||||
<Size><AbsDimension x="31"/></Size>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<!-- The channel interior: x 6..24 of the 31-wide piece, i.e. inset 6 from each edge. -->
|
||||
<!-- The channel interior: x 6..24 of the 31-wide piece, inset 6 from each edge. -->
|
||||
<Texture name="$parentFill">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor>
|
||||
@@ -374,9 +256,7 @@
|
||||
<TexCoords left="0" right="0.484375" top="0" bottom="0.08984375"/>
|
||||
</Texture>
|
||||
<!-- Crossbar + down-arrow socket + cap: right column, rows 82..107
|
||||
(v 82/256 .. 108/256) — the mirror of the top's 23, cut to put row 107 on the
|
||||
trough's bottom edge exactly as ReputationFrame's own 108/0.421875 does, which
|
||||
is what lands the socket on the arrow (BenillaScrollTrough_Seat's law). -->
|
||||
(v 82/256 .. 108/256), ending on v 0.421875 as ReputationFrame's does. -->
|
||||
<Texture name="$parentBottom" file="Interface\PaperDollInfoFrame\UI-Character-ScrollBar">
|
||||
<Size><AbsDimension y="26"/></Size>
|
||||
<Anchors>
|
||||
@@ -386,8 +266,7 @@
|
||||
<TexCoords left="0.515625" right="1.0" top="0.3203125" bottom="0.421875"/>
|
||||
</Texture>
|
||||
<!-- The stretched run between them: left column rows 30..70, plain rails only.
|
||||
Declared LAST because it anchors to the two above by name, and an anchor whose
|
||||
target is declared later falls back to the parent (ItemTextFrame's own note). -->
|
||||
Declared last: an anchor to a later sibling falls back to the parent. -->
|
||||
<Texture name="$parentMiddle" file="Interface\PaperDollInfoFrame\UI-Character-ScrollBar">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentTop" relativePoint="BOTTOMLEFT"/>
|
||||
@@ -399,22 +278,4 @@
|
||||
</Layers>
|
||||
</Frame>
|
||||
|
||||
<!-- ══ Carried here from UiPanels.xml when that file went stock (decision 1988) ══
|
||||
The placeholder icon our UIParent.lua counterpart held and the reference's does not,
|
||||
seated in the earliest file of ours every consumer loads after. The window tab template
|
||||
and its settle stood here too until 1993 put them on the chain. -->
|
||||
<Script><![CDATA[
|
||||
-- The placeholder icon for an item/spell whose texture has not arrived yet (Craft, Quest,
|
||||
-- QuestLog, Trainer and TradeSkill read it). Was ActionBar.xml's (stock since 1938), then
|
||||
-- UiPanels.xml's (stock since 1988); this file is the earliest of ours every reader loads
|
||||
-- after. A benilla name, so nothing stock reads it.
|
||||
BENILLA_FALLBACK_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
|
||||
]]></Script>
|
||||
|
||||
<!-- `FauxScrollFrameTemplate` and the `FauxScrollFrame_*`/`ScrollFrame_*`/`ScrollingEdit_*`
|
||||
API are the REFERENCE's, off the player's chain (`Interface\FrameXML\UIPanelTemplates`,
|
||||
seated below this file — so its declarations win). Our transcriptions stood here dead
|
||||
from the day that entry landed and are gone (1860). What stays is
|
||||
`BenillaScrollBarTemplate` + `BenillaScrollTroughTemplate` and their helpers, which are
|
||||
ours: six windows inherit the bar directly and the reference has no trough at all. -->
|
||||
</Ui>
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
<Ui>
|
||||
<!--
|
||||
BENILLA_ALLOW_OWN_UI=1 — the director's two LOOK deviations on the pet page's autocast
|
||||
button, over the reference's own SpellBookFrame.xml (decisions 1392/1393; re-seated by 2014
|
||||
onto the reference's own Model now that this engine renders it — 2013).
|
||||
|
||||
Since 2013 the `$parentAutoCast` <Model> on every SpellButton draws its own file
|
||||
(Interface\Buttons\UI-AutoCastButton.mdx) as a tile: four bones chasing the corners of a
|
||||
0.02-unit square, four spline emitters trailing GlowStar sparks off them, on the pane's own
|
||||
clock. The reference declares it 36x36 at CENTER (1,1) at scale 1.22 — a 0.87x rim that
|
||||
floats clear of the viewport edge, never clipped, washing the icon; the real 1.12 client
|
||||
looks the same way (director-checked, 1392), so this is taste, not fidelity, and the
|
||||
director chose the pet button's proportions instead:
|
||||
|
||||
· the shine pane squared on the button — 37x37 at CENTER (0,0), scale 1.48 — so the rim
|
||||
runs ON the viewport edge (1.024x, the pet button's own ratio) and the tile's cell, the
|
||||
widget's own scissor, halves every star; and so the glow and the brackets share a centre
|
||||
(1393's "top and right are off" was the reference's own +1,+1);
|
||||
· the corner brackets at 71.53 rather than the reference's 60 — the art fills 33/64 of
|
||||
UI-AutoCastableOverlay.blp, and 71.53 = 37 x 58/30 puts the bracket square on this
|
||||
button's corners at the pet button's 0.997x.
|
||||
|
||||
Reverting the widget to 1.12 exactly is: overlay 60, pane 36x36 CENTER (1,1), scale 1.22 —
|
||||
i.e. deleting this file. Nothing else here: the reference's file is sourced unedited.
|
||||
BENILLA_ALLOW_OWN_UI=1. Deviation: over the unedited stock SpellBookFrame.xml, the pet
|
||||
page's autocast widgets take the pet bar button's proportions.
|
||||
The `$parentAutoCast` shine (UI-AutoCastButton.mdx) is squared on the button, 37x37 at
|
||||
CENTER (0,0) and scale 1.48, so its rim runs on the viewport edge at the pet button's
|
||||
1.024x and shares the brackets' centre; the reference's is 36x36 at CENTER (1,1), 1.22.
|
||||
The corner brackets are 71.53 rather than 60: the art fills 33/64 of
|
||||
UI-AutoCastableOverlay.blp, and 71.53 = 37 x 58/30 puts them on the button's corners at
|
||||
the pet button's 0.997x. Deleting this file restores the reference's look.
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
for i = 1, 12 do
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,7 +92,7 @@ fn render_frames(frames: &[String]) -> String {
|
||||
/// diff interesting, and the extra-names side the one nothing else in this harness can see — every
|
||||
/// other ranking here is demand-driven, so it can only ever report what an addon *asked* for.
|
||||
///
|
||||
/// The reference side is `reference/1.12-globals.tsv`, itself generated from wow-re's live capture.
|
||||
/// The reference side is `reference/1.12-globals.tsv`, generated from a live reference capture.
|
||||
/// `lod` rows are the twelve LoadOnDemand `Blizzard_*` addons' names, unioned in by 1200 because a
|
||||
/// live dump misses them unless the player opened those windows; they are counted separately rather
|
||||
/// than silently folded in, since "absent from us" means something different for a window nobody
|
||||
@@ -736,9 +736,8 @@ fn main() {
|
||||
// **WHOSE package is incomplete** — printed immediately before the ranked blockers, because it
|
||||
// is the line that stops a session hunting for a client bug that is not one. A `.toc` entry
|
||||
// whose file the addon does not ship is the ADDON's defect, and the reference client's
|
||||
// behaviour there is what ours already does: log `Couldn't open %s` and carry on
|
||||
// (wow-re `ui/scratch/xml-toc-path-resolution.md` §4). Nothing is subtracted from the headline
|
||||
// — 1213 — so both readings stay available.
|
||||
// behaviour there is what ours already does: log `Couldn't open %s` and carry on (`0x6edaa0`).
|
||||
// Nothing is subtracted from the headline — 1213 — so both readings stay available.
|
||||
let own: Vec<&addon_harness::AddonReport> = reports
|
||||
.iter()
|
||||
.filter(|r| !r.absent_own_files.is_empty())
|
||||
|
||||
@@ -115,10 +115,9 @@ pub struct AddonReport {
|
||||
/// Not "did every entry resolve" — that is [`Self::absent_own_files`] and
|
||||
/// [`Self::absent_foreign_files`], and it is a different question because the reference
|
||||
/// answers it differently: a manifest line naming no file is `"Couldn't open %s"` in
|
||||
/// FrameXML.log and the walk carries straight on (wow-re
|
||||
/// `ui/scratch/xml-toc-path-resolution.md` §4, VERIFIED). An addon shipping an incomplete zip
|
||||
/// *works* on a real client, so counting it here made the survey say the opposite of the truth
|
||||
/// about 24 addons — FuBar itself among them, on two locale files it does not ship.
|
||||
/// FrameXML.log and the walk carries straight on (`0x6edaa0`). An addon shipping an incomplete
|
||||
/// zip *works* on a real client, so counting it here made the survey say the opposite of the
|
||||
/// truth about 24 addons — FuBar itself among them, on two locale files it does not ship.
|
||||
///
|
||||
/// **[`Self::errors`] is unchanged and still carries those rows verbatim** (1213), so every
|
||||
/// number in every past record is still readable off it; what changed, deliberately and with
|
||||
@@ -144,11 +143,11 @@ pub struct AddonReport {
|
||||
///
|
||||
/// A different question from [`Self::absent_own_files`] and kept apart from it: this addon's
|
||||
/// package is fine, it wants a neighbour. The corpus's two are Auctioneer and BeanCounter
|
||||
/// reaching `..\Blizzard_AuctionUI\Blizzard_AuctionUITemplates.xml` — which wow-re records as
|
||||
/// **RESOLVING** in the real client (`ui/scratch/xml-toc-path-resolution.md` §5 case 2, by
|
||||
/// name), because there the file layer can see `Blizzard_AuctionUI` inside `patch.MPQ`. Ours
|
||||
/// reads the AddOns directory only, so it misses. That one IS ours, and the split is what
|
||||
/// makes it visible instead of averaging with the row above.
|
||||
/// reaching `..\Blizzard_AuctionUI\Blizzard_AuctionUITemplates.xml` — which the real client
|
||||
/// **RESOLVES** (`0x6ede10` collapses the `..`), because there the file layer can see
|
||||
/// `Blizzard_AuctionUI` inside `patch.MPQ`. Ours reads the AddOns directory only, so it
|
||||
/// misses. That one IS ours, and the split is what makes it visible instead of averaging
|
||||
/// with the row above.
|
||||
pub absent_foreign_files: Vec<String>,
|
||||
/// Names it calls that the VM does not have — see the module doc on what this is worth.
|
||||
pub missing_globals: Vec<String>,
|
||||
@@ -1812,6 +1811,7 @@ fn seat_a_session(script: &mut UiScript) {
|
||||
script.set_quest_log(QuestLogState {
|
||||
entries: vec![header, in_progress, done],
|
||||
num_quests: 2,
|
||||
hidden_quest_ids: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2083,11 +2083,10 @@ struct FileLoad {
|
||||
///
|
||||
/// It is `false` for an addon whose only failures are absent files, because on the reference
|
||||
/// that addon *loads*: `0x6edaa0` logs `"Couldn't open %s"` and returns null, and the walk
|
||||
/// carries on to the next manifest entry with nothing raised (wow-re
|
||||
/// `ui/scratch/xml-toc-path-resolution.md` §4, VERIFIED — the same rule 2107 unified for the
|
||||
/// two loaders that reach it from Lua). Until this existed the survey scored 24 corpus addons
|
||||
/// as not surviving a session start on nothing but a locale file their own zip omits, FuBar
|
||||
/// and 22 of its plugins among them.
|
||||
/// carries on to the next manifest entry with nothing raised (the same rule 2107 unified for
|
||||
/// the two loaders that reach it from Lua). Until this existed the survey scored 24 corpus
|
||||
/// addons as not surviving a session start on nothing but a locale file their own zip omits,
|
||||
/// FuBar and 22 of its plugins among them.
|
||||
raised: bool,
|
||||
}
|
||||
|
||||
@@ -2106,10 +2105,10 @@ fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> Fi
|
||||
let Some(bytes) = read_under(root, &path) else {
|
||||
errors.push(format!("{file}: not found"));
|
||||
// WHOSE package is incomplete. `join_ref` has already collapsed the `..`s the way the
|
||||
// client does (wow-re `ui/scratch/xml-toc-path-resolution.md` §2), so the resolved
|
||||
// path is what the real file layer would be handed — and an entry that still points
|
||||
// inside the addon's own folder is the addon shipping a manifest it does not satisfy,
|
||||
// while one pointing out of it wants a neighbour that is not installed.
|
||||
// client does (`0x6ede10`), so the resolved path is what the real file layer would be
|
||||
// handed — and an entry that still points inside the addon's own folder is the addon
|
||||
// shipping a manifest it does not satisfy, while one pointing out of it wants a
|
||||
// neighbour that is not installed.
|
||||
let own = path
|
||||
.strip_prefix(base.as_str())
|
||||
.is_some_and(|rest| rest.starts_with('/'));
|
||||
@@ -4134,12 +4133,12 @@ mod dependency_tests {
|
||||
/// are this, all one family: `DPSMate_CureDisease.toc` names eight files and the folder
|
||||
/// holds six, because the three `*Received*` ones were split into a sibling addon and the
|
||||
/// manifest was never updated. Our loader is behaving correctly (the reference logs
|
||||
/// `Couldn't open %s` and carries on — wow-re `ui/scratch/xml-toc-path-resolution.md` §4).
|
||||
/// `Couldn't open %s` at `0x6edaa0` and carries on).
|
||||
/// - **`foreign`** — the entry escapes the addon's folder with `..`, which the client supports
|
||||
/// and `join_ref` reproduces, into a folder that is not installed. The corpus's two are
|
||||
/// Auctioneer and BeanCounter reaching `..\Blizzard_AuctionUI\...`, which wow-re records as
|
||||
/// **RESOLVING** in the real client (§5 case 2, by name) because its file layer can see that
|
||||
/// folder inside `patch.MPQ`. That one IS ours.
|
||||
/// Auctioneer and BeanCounter reaching `..\Blizzard_AuctionUI\...`, which the real client
|
||||
/// **RESOLVES** (`0x6ede10` collapses the `..`) because its file layer can see that folder
|
||||
/// inside `patch.MPQ`. That one IS ours.
|
||||
///
|
||||
/// The assertion that matters is that the two never merge: a single "files not found" count
|
||||
/// would average a broken package with a real client gap and read as one number.
|
||||
@@ -4275,9 +4274,9 @@ mod dependency_tests {
|
||||
// **NOTHING is subtracted from `errors`** (1213) — both rows are still there, verbatim, so
|
||||
// every figure any past record quoted is still readable off this list. What changed in
|
||||
// 2155 is which of them `loaded` counts: on the reference these addons LOAD, because
|
||||
// `0x6edaa0` logs `Couldn't open %s` and the walk carries on with nothing raised
|
||||
// (`ui/scratch/xml-toc-path-resolution.md` §4). Both halves are asserted together,
|
||||
// because either one alone is a rule that has already been got wrong in both directions.
|
||||
// `0x6edaa0` logs `Couldn't open %s` and the walk carries on with nothing raised. Both
|
||||
// halves are asserted together, because either one alone is a rule that has already been
|
||||
// got wrong in both directions.
|
||||
for r in [short, wants] {
|
||||
assert!(
|
||||
r.loaded,
|
||||
@@ -4405,6 +4404,7 @@ mod dependency_tests {
|
||||
/// was an `inherits=` in their own XML, none of which appeared in the `CreateFrame` ranking.
|
||||
#[test]
|
||||
fn the_inherits_census_counts_templates_and_not_fonts() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let tmp =
|
||||
std::env::temp_dir().join(format!("benilla-harness-inherits-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
@@ -4572,6 +4572,7 @@ mod dependency_tests {
|
||||
/// which is exactly why the old table could not see any of it.
|
||||
#[test]
|
||||
fn the_per_kind_census_finds_a_verb_wired_to_the_wrong_kind() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let tmp =
|
||||
std::env::temp_dir().join(format!("benilla-harness-perkind-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
@@ -4969,6 +4970,7 @@ mod dependency_tests {
|
||||
/// rather than a guess.
|
||||
#[test]
|
||||
fn a_published_name_carries_its_kind() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let script = seated_vm();
|
||||
assert_eq!(script.widget_kind("UIErrorsFrame"), Some("MessageFrame"));
|
||||
assert_eq!(script.widget_kind("UIParent"), Some("Frame"));
|
||||
@@ -5093,6 +5095,7 @@ mod dependency_tests {
|
||||
/// (see `drive_ui_probe`'s own comment), and a probe that cannot fail is not a probe.
|
||||
#[test]
|
||||
fn the_ui_probe_hovers_and_records_what_a_tooltip_hook_raises() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
seat_a_session(&mut script);
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
//!
|
||||
//! - **Over-reports**: any quad from a new widget counts, including one drawn off-screen or under
|
||||
//! another window. "Something reached the render list" is not "the player can see it"; the
|
||||
//! director's eye is still the judge of that (the contract §7). Over-reporting is the deliberate
|
||||
//! director's eye is still the judge of that (`docs/METHOD.md` §7). Over-reporting is the deliberate
|
||||
//! direction — a silent under-report is the failure mode this whole column exists to end.
|
||||
//! - **Under-reports** in one known shape: an addon that changes an *existing* widget in place —
|
||||
//! `SetTexture` on one of our regions, `SetBackdrop` on one of our frames — creates no new
|
||||
|
||||
@@ -87,6 +87,7 @@ impl Drop for Fixtures {
|
||||
/// not — and "creates a frame" is not "paints".
|
||||
#[test]
|
||||
fn the_render_column_can_fail() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let fx = Fixtures::new("cannotfail");
|
||||
// Paints: a texture on a window of its own.
|
||||
fx.addon(
|
||||
@@ -179,22 +180,6 @@ fn the_render_column_can_fail() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Where the corpus might be — the `ui_chat::ace_gate_tests` resolver, so a machine without the
|
||||
/// third-party corpus skips rather than reddens.
|
||||
fn corpus() -> Option<PathBuf> {
|
||||
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
|
||||
let p = PathBuf::from(over);
|
||||
if p.is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
(2usize..=4)
|
||||
.filter_map(|up| manifest.ancestors().nth(up))
|
||||
.map(|root| root.join("wow-addons-vanilla"))
|
||||
.find(|c| c.is_dir())
|
||||
}
|
||||
|
||||
/// **The oracle: two real addons, opposite director-verified outcomes.**
|
||||
///
|
||||
/// `!OmniCC` puts cooldown countdown numbers on the director's screen — it works, and it works by
|
||||
@@ -209,10 +194,9 @@ fn corpus() -> Option<PathBuf> {
|
||||
/// If this test ever disagrees with the director's eyes, **the test is wrong**.
|
||||
#[test]
|
||||
fn the_directors_two_verified_addons_come_out_on_opposite_sides() {
|
||||
let Some(corpus) = corpus() else {
|
||||
eprintln!("skipping: no vanilla addon corpus (set $BENILLA_ADDON_CORPUS)");
|
||||
return;
|
||||
};
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
// The one resolver, and a skip the gate can refuse (`benilla_formats::install`).
|
||||
let corpus = benilla_formats::addon_corpus_or_skip!();
|
||||
// A root holding just these four, SYMLINKED rather than copied. Surveying the whole corpus
|
||||
// here would put a minute onto `cargo test --workspace` for four rows — the full sweep is the
|
||||
// `addon_harness` example's job, not a unit test's.
|
||||
|
||||
@@ -248,22 +248,6 @@ fn the_use_column_can_fail() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Where the corpus might be — the same resolver `render_tests` uses, so a machine without the
|
||||
/// third-party corpus skips rather than reddens.
|
||||
fn corpus() -> Option<PathBuf> {
|
||||
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
|
||||
let p = PathBuf::from(over);
|
||||
if p.is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
(2usize..=4)
|
||||
.filter_map(|up| manifest.ancestors().nth(up))
|
||||
.map(|root| root.join("wow-addons-vanilla"))
|
||||
.find(|c| c.is_dir())
|
||||
}
|
||||
|
||||
/// **The real oracle: the director's two verified addons.**
|
||||
///
|
||||
/// - `!OmniCC` **works** — its countdown numbers are on their screen. It must not be reported as
|
||||
@@ -278,10 +262,9 @@ fn corpus() -> Option<PathBuf> {
|
||||
/// silently stops touching them is exactly how this instrument has been wrong four times.
|
||||
#[test]
|
||||
fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() {
|
||||
let Some(corpus) = corpus() else {
|
||||
eprintln!("skipping: no vanilla addon corpus (set $BENILLA_ADDON_CORPUS)");
|
||||
return;
|
||||
};
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
// The one resolver, and a skip the gate can refuse (`benilla_formats::install`).
|
||||
let corpus = benilla_formats::addon_corpus_or_skip!();
|
||||
let fx = Fixtures::new("oracle");
|
||||
for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] {
|
||||
// MONKEY (volumetric fog): share the portable fixture used by the render oracle.
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! `GetMinimapZoneText`/`GetZonePVPInfo` (decision 0287; corrected to the bytes by its fold-back
|
||||
//! record).
|
||||
//!
|
||||
//! Byte-verified model (wow-re ui `zonetext-pvpinfo.md`; `0x494780` is the one updater all three
|
||||
//! zone events fire from, on the per-update area resolve):
|
||||
//! The model (`0x494780` is the one updater all three zone events fire from, on the per-update
|
||||
//! area resolve):
|
||||
//!
|
||||
//! - The client caches `(zoneId, zoneText, subzoneText)` and compares per update: **zone id
|
||||
//! changed → `ZONE_CHANGED_NEW_AREA` fires alone**; else **either text changed →
|
||||
@@ -23,10 +23,10 @@
|
||||
//! ownerless zone** (nil is structural failure only); `factionName` = FactionGroup.dbc's
|
||||
//! localized Name for the zone's mask bit; pvpType is never "arena"; realm type never enters.
|
||||
//! - **The indoor bit** is the player's faces-only down-ray verdict
|
||||
//! ([`benilla_world::wmo_portal::CurrentAreaInterior`] — wow-re `zonetext-indoor-bit.md`, the CGLight
|
||||
//! node's `+0x90` bit 0): indoors ⇔ the nearest surface below is a WMO face whose group lacks
|
||||
//! MOGP `0x8` EXTERIOR. The abbey yard is terrain-below ⇒ outdoors; the flip is the doorway.
|
||||
//! - **The indoor naming** (`0x67e670` (d), byte-pinned by (d-ii)): while indoors and the hit
|
||||
//! ([`benilla_world::wmo_portal::CurrentAreaInterior`] — the CGLight node's `+0x90` bit 0, set
|
||||
//! by `0x6a87f0`): indoors ⇔ the nearest surface below is a WMO face whose group lacks MOGP
|
||||
//! `0x8` EXTERIOR. The abbey yard is terrain-below ⇒ outdoors; the flip is the doorway.
|
||||
//! - **The indoor naming** (`0x67e670`): while indoors and the hit
|
||||
//! GROUP is unchanged, the WHOLE updater is skipped (the dedup `[0x868608]`/`[0x86860c]`). On
|
||||
//! an indoor change: **query A** (the whole-WMO −1 row, exact key, `0x69d830`) overrides the
|
||||
//! ZONE slot — only when its resolved name is non-empty AND differs from the current subzone
|
||||
@@ -43,9 +43,7 @@
|
||||
//! half is modelled where the map data lives, in
|
||||
//! [`crate::ui_world_map::world_enter_selection`] — the gate there is the same precondition
|
||||
//! (`0x67e510` never reaches this updater with a zero zone id), read off our own area feed rather
|
||||
//! than off the cache below. wow-re carved it as
|
||||
//! `system/ui/scratch/worldmap-selection-autosync.md`, correcting this note's own earlier source,
|
||||
//! which had dismissed the call as unrelated.
|
||||
//! than off the cache below.
|
||||
//!
|
||||
//! The area *authority* stays `terrain_stream::CurrentArea` (decision 0232 — the MCNK `areaId`
|
||||
//! with the WMO-interior override, now off the same faces-only claim). Host globals are written
|
||||
@@ -110,9 +108,8 @@ struct ZoneCache {
|
||||
minimap_text: String,
|
||||
/// The previous update's indoor bit (`[0x868608]`).
|
||||
indoor: bool,
|
||||
/// The previous hit group's identity (`[0x86860c]` — the client stores the group index,
|
||||
/// (d-ii); we scope the MOGP uniqueID by WMO): same group while indoors ⇒ the whole updater
|
||||
/// is skipped.
|
||||
/// The previous hit group's identity (`[0x86860c]` — the client stores the group index; we
|
||||
/// scope the MOGP uniqueID by WMO): same group while indoors ⇒ the whole updater is skipped.
|
||||
wmo_id: u32,
|
||||
wmo_group: u32,
|
||||
}
|
||||
@@ -181,13 +178,13 @@ fn feed_zone_events(
|
||||
leaf_row.name.clone()
|
||||
};
|
||||
|
||||
// The indoor naming (`0x67e670` (d)/(d-ii), module doc): dedup-skip while the hit group
|
||||
// The indoor naming (`0x67e670`, module doc): dedup-skip while the hit group
|
||||
// holds; else the default-row name (query A) may override the zone slot and the group-row
|
||||
// name (query B) re-populates the subzone — both EXACT-key lookups (no name-set retry).
|
||||
let mut wmo_group = 0u32;
|
||||
if let Some((k, group, default)) = world.area_interior_rows() {
|
||||
// The dedup key: the hit GROUP's identity (the client's `[groupRec+0x7c]` group index,
|
||||
// nonzero-gated — (d-ii)). We key the MOGP uniqueID scoped by WMO id: it changes exactly
|
||||
// nonzero-gated). We key the MOGP uniqueID scoped by WMO id: it changes exactly
|
||||
// when the hit group does, and — a named divergence — never aliases across two adjacent
|
||||
// buildings the way the client's raw per-WMO index can.
|
||||
wmo_group = k.group_area_id;
|
||||
@@ -400,7 +397,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The indoor naming law (`0x67e670` (d)) against the REAL abbey/inn data shapes — the
|
||||
/// The indoor naming law (`0x67e670`) against the REAL abbey/inn data shapes — the
|
||||
/// override-skip (whole-WMO name == yard subzone) and the override-fire (inn) branches,
|
||||
/// run through the same WmoAreaCatalog the live feed reads. Skips without client data.
|
||||
#[test]
|
||||
@@ -419,7 +416,7 @@ mod tests {
|
||||
group.area_table_id, 24,
|
||||
"the leaf stays Northshire Abbey (24)"
|
||||
);
|
||||
// The dedup rides the hit-group identity ((d-ii): the client's group index; our MOGP
|
||||
// The dedup rides the hit-group identity (the client's group index; our MOGP
|
||||
// uniqueID) — distinct per room, so a room hop re-fires and standing still skips.
|
||||
let library = cat.group_row(59, 1, 1943).expect("library row");
|
||||
assert_ne!(group.id, library.id, "distinct rows exist per room");
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
//!
|
||||
//! ## The law, from the reference
|
||||
//!
|
||||
//! **VERIFIED** against `0x5e2110` (the per-frame check) and `0x5e22d0` (containment), wow-5875-re
|
||||
//! `object-layer/scratch/w2b1-decomp.c`; the geometry itself is
|
||||
//! From `0x5e2110` (the per-frame check) and `0x5e22d0` (containment); the geometry itself is
|
||||
//! [`AreaTriggerRow::contains`](benilla_formats::AreaTriggerRow::contains).
|
||||
//!
|
||||
//! - The map's rows are the only candidates (`0x5e2080` narrows the map-sorted table to a
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! kit carries no effect models and no anim at all — its entire visual is one CharProc — so the
|
||||
//! effects-only watcher resolved the kit, found an empty slot list, and dropped it on the floor.
|
||||
//!
|
||||
//! ## The mechanism (VERIFIED — wow-re `ghost-death-visuals.md` §2, `state-kit-aura-lifecycle.md`)
|
||||
//! ## The mechanism
|
||||
//!
|
||||
//! `UNIT_FIELD_AURA` slot change → the aura watcher `0x604d00` → `0x6123f0` → **`0x5ff350`
|
||||
//! PlayAuraStateVisual**: the slot's spell → `Spell.dbc` → `SpellVisual` → **field 4 = the state
|
||||
@@ -21,10 +21,9 @@
|
||||
//! - **proc 14 = translucency** (`0x60d972`): the param becomes a node keyed by spell id
|
||||
//! (`node+0x18`, value at `node+0x78`), linked at the HEAD of the unit's list `unit+0xb50`. The
|
||||
//! unit's effective alpha is then recomputed by `0x60d180` as **`baseAlpha × the head node's
|
||||
//! factor`** — at most one node term, never a product over the chain (wow-re
|
||||
//! `base-render-alpha.md` §4, correcting this note's earlier `Π` gloss) — `baseAlpha` from the
|
||||
//! factor`** — at most one node term, never a product over the chain — `baseAlpha` from the
|
||||
//! vtbl+0x6c getter `0x60d2d0` (`CreatureDisplayInfo+0x14 × 1/255`, the SAME slot in the unit
|
||||
//! and player vtables: "players 1.0" was the data talking, not a type fork — §6) — and handed
|
||||
//! and player vtables: a player's 1.0 is the data talking, not a type fork) — and handed
|
||||
//! to **`0x614f80` StartAlphaFade(target, 1000 ms)**, the same ramp block (`+0xec..0x100`) and
|
||||
//! the same `clamp01(t)³` ease (`0x614a90`) our appear-fade already rides
|
||||
//! ([`benilla_world::model_fade::fade_alpha`]). Per frame `model+0x180 = master × fade`
|
||||
@@ -41,10 +40,10 @@
|
||||
//!
|
||||
//! The vis-flag is a decoy and must not be used here: `UNIT_FIELD_BYTES_1` byte 3's CREEP (stealth)
|
||||
//! and GHOST bits drive **no** body render at all — an exhaustive census of every `+0x213` reference
|
||||
//! found three sites, all nameplate/marker suppression (wow-re `ghost-death-visuals.md` §1). The two
|
||||
//! co-travel only because the server sets the flag and the aura from the same spell. Keying the body
|
||||
//! off the flag would be the wrong mechanism that happens to look right on stealth and then fails on
|
||||
//! every other member of the family.
|
||||
//! found three sites, all nameplate/marker suppression (`0x5ff80d`, `0x607101`, `0x60f62e`). The
|
||||
//! two co-travel only because the server sets the flag and the aura from the same spell. Keying
|
||||
//! the body off the flag would be the wrong mechanism that happens to look right on stealth and
|
||||
//! then fails on every other member of the family.
|
||||
//!
|
||||
//! ## The shipped data (read from the real 5875 `SpellVisualKit.dbc` this session —
|
||||
//! `benilla-extract charprocs` censuses it)
|
||||
@@ -96,7 +95,7 @@
|
||||
//!
|
||||
//! Types 2, 7 and 13 also appear on state kits (Berserk/Bloodlust's 2, the Sap/Feign-Death 7, the
|
||||
//! "Glowy (Red)" 13) and have **no verified mechanism** — they fall through [`node_for`]'s single `_`
|
||||
//! arm, which is where each lands as one match arm the day its RE returns.
|
||||
//! arm, which is where each lands as one match arm the day its mechanism is known.
|
||||
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::mesh::MeshTag;
|
||||
@@ -154,10 +153,9 @@ pub(crate) struct AuraNodes {
|
||||
/// driver's own again.
|
||||
rate: Vec<(u32, f32)>,
|
||||
/// `baseAlpha`: the display row's `CreatureModelAlpha / 255` — for EVERY unit, players
|
||||
/// included; the getter `0x60d2d0` has no type fork, a normal character's row just says 255
|
||||
/// (`base-render-alpha.md` §6, correcting the "players 1.0" gloss). Owned by
|
||||
/// [`refresh_base_alpha`], which re-resolves it on every display-id change (a shapeshift
|
||||
/// swaps it live) and ramps to the new value.
|
||||
/// included; the getter `0x60d2d0` has no type fork, a normal character's row just says 255.
|
||||
/// Owned by [`refresh_base_alpha`], which re-resolves it on every display-id change (a
|
||||
/// shapeshift swaps it live) and ramps to the new value.
|
||||
base: f32,
|
||||
/// Where the ramp is now — what the parts actually render at.
|
||||
current: f32,
|
||||
@@ -188,8 +186,8 @@ impl AuraNodes {
|
||||
|
||||
/// The reference's `0x60d180`: `baseAlpha × the HEAD alpha node's factor` — at most ONE node
|
||||
/// term, skipped entirely when the list is empty (`0x60d195 je`), never a product over the
|
||||
/// chain (wow-re `base-render-alpha.md` §4, correcting `ghost-death-visuals.md`'s `Π` gloss).
|
||||
/// Nodes are linked at the head as they install, so the newest one is the term that counts.
|
||||
/// chain. Nodes are linked at the head as they install, so the newest one is the term that
|
||||
/// counts.
|
||||
fn target(&self) -> f32 {
|
||||
let head = self.alpha.first().map_or(1.0, |(_, f)| *f);
|
||||
(self.base * head).clamp(0.0, 1.0)
|
||||
@@ -365,7 +363,7 @@ pub(crate) struct AnimRateFreeze;
|
||||
/// inherit the freeze and outlive it.
|
||||
///
|
||||
/// **Reasserted every frame, and that is the faithful shape, not a belt-and-braces clamp.** The
|
||||
/// reference's rate lives on the bone (`+0xb0`, the factor `timebase.md` §2's clock multiplies its
|
||||
/// reference's rate lives on the bone (`+0xb0`, the factor the clock at `0x71458e` multiplies its
|
||||
/// window by) and **arming an animation does not touch it**: op4 `0x7121a0`'s success leg writes the
|
||||
/// track index and the cursors and leaves `+0xb0` alone (only its disarm leg and `SetBoneAnimSpeed
|
||||
/// 0x712910` write it). So "rate 0 until the aura is reaped" is literally the reference's state
|
||||
@@ -493,7 +491,7 @@ pub(crate) fn node_for(proc: benilla_formats::CharProc) -> Option<AuraNode> {
|
||||
///
|
||||
/// `base_alpha` here only SEEDS a node set created by an aura edge — its steady-state owner is
|
||||
/// [`refresh_base_alpha`], which re-resolves it on every display change. The getter is the same
|
||||
/// for players and creatures (the display row's `CreatureModelAlpha`; `base-render-alpha.md` §6).
|
||||
/// for players and creatures (the display row's `CreatureModelAlpha`, read by `0x60d2d0`).
|
||||
/// A unit whose display is unknown falls back to `1.0` — opaque, i.e. the aura's factor alone,
|
||||
/// which is the safe direction (the alternative would hide a unit whose row simply failed to
|
||||
/// load).
|
||||
@@ -566,9 +564,8 @@ fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) {
|
||||
|
||||
/// Install one spell's nodes, replacing any it already had (an aura re-applied by a second caster
|
||||
/// holds a second slot but installs one node set — the reference's own per-spell-id keying).
|
||||
/// New nodes link **at the head** (`base-render-alpha.md` §4: the newest node is the one the
|
||||
/// recompute reads), which is why both lists insert at the front here and both readers take
|
||||
/// `.first()`.
|
||||
/// New nodes link **at the head** (the newest node is the one the recompute `0x60d180` reads),
|
||||
/// which is why both lists insert at the front here and both readers take `.first()`.
|
||||
fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) {
|
||||
n.alpha.retain(|(s, _)| *s != spell_id);
|
||||
n.tint.retain(|(s, _)| *s != spell_id);
|
||||
@@ -587,7 +584,7 @@ fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) {
|
||||
/// the spell, the nodes it installed, and the target the ramp is now aiming at.
|
||||
///
|
||||
/// This is how "stealth shows nothing" is *reproduced and then closed* without eyeballing a capture
|
||||
/// (`method.md` §5/§6): with the aura layer dead the trace is silent on `.cast 1784`; with it live
|
||||
/// (`docs/METHOD.md` §5/§6): with the aura layer dead the trace is silent on `.cast 1784`; with it live
|
||||
/// the same press prints `aur arm spell=1784 alpha=[0.3] -> base 1.00 target 0.30`, and the drop
|
||||
/// prints the ramp back to 1.00. It rides the shared move-trace sink so an aura edge interleaves on
|
||||
/// one timeline with the movement/anim lines — a "why did it not fade when I moved" question needs
|
||||
@@ -616,10 +613,9 @@ fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) {
|
||||
|
||||
/// `baseAlpha` for a unit — the getter `0x60d2d0`: its CURRENT display row's
|
||||
/// `CreatureModelAlpha / 255`, for **every** unit kind. There is no player override — the same
|
||||
/// vtable slot in both classes, byte-identical (`base-render-alpha.md` §6); an unshifted
|
||||
/// character reads 1.0 only because its row (49, 50, …) says 255, and a shaman wearing Ghost
|
||||
/// Wolf's display 4613 reads 102/255 = 0.4 through exactly this path. No row → 1.0 (the
|
||||
/// `0x60d2e4` NULL fallback).
|
||||
/// vtable slot in both classes, byte-identical; an unshifted character reads 1.0 only because its
|
||||
/// row (49, 50, …) says 255, and a shaman wearing Ghost Wolf's display 4613 reads 102/255 = 0.4
|
||||
/// through exactly this path. No row → 1.0 (the `0x60d2e4` NULL fallback).
|
||||
fn base_alpha(
|
||||
entity: Entity,
|
||||
units: &Query<&crate::net::NetEntity>,
|
||||
@@ -646,7 +642,7 @@ fn display_base_alpha(
|
||||
}
|
||||
|
||||
/// Re-resolve every unit's base alpha when its display changes — the reference's DISPLAYID
|
||||
/// watcher path (`base-render-alpha.md` §5: `0x604990 → 0x60abe0 → 0x60afb0` caches the row →
|
||||
/// watcher path (`0x604990 → 0x60abe0 → 0x60afb0` caches the row →
|
||||
/// `0x60ad9f → 0x60d180 → StartAlphaFade(target, 1000 ms)`), self-gated on a real record change
|
||||
/// (`0x60ae10`). This is what makes a unit with `CreatureModelAlpha < 255` translucent with **no
|
||||
/// aura in play** — the shifted shaman's ghost look — and what ramps it back to opaque when the
|
||||
|
||||
@@ -66,8 +66,8 @@ fn the_dispatch_names_only_the_verified_procs() {
|
||||
Some(AuraNode::Alpha(0.0)),
|
||||
"kit 5129's 0.0 is a real value, not an absent one"
|
||||
);
|
||||
// 9222653 = 0x8CB9FD — the ghost's pale blue-white, byte-cited in wow-re as 0xFF8CB9FD after the
|
||||
// client ORs the opaque byte on.
|
||||
// 9222653 = 0x8CB9FD — the ghost's pale blue-white, 0xFF8CB9FD once the client ORs the opaque
|
||||
// byte on (`0x60d8cc`).
|
||||
assert_eq!(
|
||||
node_for(proc(char_proc_type::TINT, 9_222_653.0)),
|
||||
Some(AuraNode::Tint([0x8C, 0xB9, 0xFD])),
|
||||
@@ -99,10 +99,9 @@ fn the_dispatch_names_only_the_verified_procs() {
|
||||
// ── The target and the ramp ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `0x60d180`: the target is `baseAlpha × the HEAD node's factor` — at most one node term, the
|
||||
/// newest (nodes link at the list head as they install), never a product over the chain (wow-re
|
||||
/// `base-render-alpha.md` §4, correcting the earlier `Π` reading). Stacking stealth (0.3) then the
|
||||
/// ghost aura (0.5) reads 0.5 — whichever landed last — and dropping the head hands the term to
|
||||
/// the next node down.
|
||||
/// newest (nodes link at the list head as they install), never a product over the chain.
|
||||
/// Stacking stealth (0.3) then the ghost aura (0.5) reads 0.5 — whichever landed last — and
|
||||
/// dropping the head hands the term to the next node down.
|
||||
#[test]
|
||||
fn the_target_is_base_times_the_newest_node() {
|
||||
let mut n = AuraNodes::new(1.0);
|
||||
@@ -135,7 +134,7 @@ fn the_target_is_base_times_the_newest_node() {
|
||||
|
||||
/// The display-swap leg ([`refresh_base_alpha`]'s retarget): a base change rides the same 1000 ms
|
||||
/// cubic ramp an aura node does — Ghost Wolf's 102/255 eases in from opaque, and swapping home
|
||||
/// eases back — never snaps (`base-render-alpha.md` §5: the fade is part of the mechanism).
|
||||
/// eases back — never snaps (the fade, `0x614f80`, is part of the mechanism).
|
||||
#[test]
|
||||
fn a_base_change_rides_the_same_ramp() {
|
||||
let wolf = 102.0 / 255.0;
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
//! on the confirmed switch back to general — the reference's own semantics.
|
||||
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
use bevy::input::mouse::AccumulatedMouseScroll;
|
||||
use bevy::input::mouse::{AccumulatedMouseScroll, MouseScrollUnit};
|
||||
use bevy::input::ButtonState;
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -434,6 +434,51 @@ fn sync_dispatch(script: Option<NonSendMut<UiScript>>, mut dispatch: ResMut<Bind
|
||||
script.fire_event("UPDATE_BINDINGS", vec![]);
|
||||
}
|
||||
|
||||
/// A wheel delta in **lines** — notches — whatever unit the OS reported it in. A mouse wheel
|
||||
/// arrives as `Line`; a macOS trackpad or Magic Mouse arrives as `Pixel`, many small deltas a
|
||||
/// frame apart for one gesture, and Bevy's own conversion factor is the one normalisation.
|
||||
/// Shared by every wheel reader so none of them keeps its own copy of the constant.
|
||||
pub(crate) fn wheel_lines(unit: MouseScrollUnit, dy: f32) -> f32 {
|
||||
match unit {
|
||||
MouseScrollUnit::Line => dy,
|
||||
MouseScrollUnit::Pixel => dy / MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR,
|
||||
}
|
||||
}
|
||||
|
||||
/// **Whole notches out of a stream of line deltas**, carrying the fraction between frames.
|
||||
///
|
||||
/// For the consumers that act per NOTCH rather than per unit of travel — the UI's
|
||||
/// `OnMouseWheel` (stock `ScrollFrameTemplate_OnMouseWheel` moves half a pane per call and
|
||||
/// `ChatFrame_OnMouseWheel` a line, both off the sign alone) and the realm list's row step. Fed a
|
||||
/// trackpad's per-frame pixel trickle raw, each of those fired once per frame, so a gentle swipe
|
||||
/// slammed a pane to its end; through this, ten frames of a tenth of a line are one notch.
|
||||
///
|
||||
/// A reversal drops the carried fraction: travel the other way is a new gesture, and it should
|
||||
/// not first have to undo the remainder of the last one.
|
||||
///
|
||||
/// A `Resource` for the UI's feed (a fact about the input device's gesture, which outlives any one
|
||||
/// VM), a `Local` for the realm list's.
|
||||
#[derive(Default, Resource)]
|
||||
pub(crate) struct WheelNotches {
|
||||
carry: f32,
|
||||
}
|
||||
|
||||
impl WheelNotches {
|
||||
/// Add `lines` of travel; returns the signed whole notches it completes (usually 0 or ±1).
|
||||
pub(crate) fn feed(&mut self, lines: f32) -> i32 {
|
||||
if lines == 0.0 || !lines.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
if self.carry != 0.0 && self.carry.signum() != lines.signum() {
|
||||
self.carry = 0.0;
|
||||
}
|
||||
self.carry += lines;
|
||||
let whole = self.carry.trunc();
|
||||
self.carry -= whole;
|
||||
whole as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// The dispatch pass — see the module doc. Runs right after the UI key feed (same frame's
|
||||
/// capture gate), before `WorldStage::Input` (a bound key must act this frame, once).
|
||||
fn latch_and_dispatch(
|
||||
@@ -540,8 +585,7 @@ fn latch_and_dispatch(
|
||||
// rising edge of `capture.typing`, on a misreading of `0x514490` as "the reference's chat-focus
|
||||
// handler": its sole caller `0x493058` hangs off the CSimpleTop root's WM_ACTIVATE callback
|
||||
// slot (`[root+0x1134]`, event category 2, payload 0 = deactivate), so it is the **OS
|
||||
// window-deactivate** handler, not a UI-focus one (wow-re `loading-screen-input-law.md`; the
|
||||
// conflated phrasing was `rf79-autorun-cancel-set.md`'s "Chat EditBox / window focus" row).
|
||||
// window-deactivate** handler, not a UI-focus one.
|
||||
// In the reference a focused box merely turns the movement handlers into no-ops and the
|
||||
// direction bits are *frozen, not cleared* — so holding W and pressing ENTER keeps you
|
||||
// running, and the world map eating the `M` that closes it keeps you running too. Both
|
||||
@@ -681,19 +725,14 @@ fn latch_and_dispatch(
|
||||
|
||||
// ── Wheel ── **a notch is a press AND its release, back to back.** The reference builds one
|
||||
// chord and hands it to `CBindings::ExecuteBinding` twice — `isDown=1` at `0x483d6f`, then
|
||||
// `isDown=0` at `0x483d82` (wow-re `system/ui/ui.md` §3, VERIFIED) — so a `runOnUp` command
|
||||
// `isDown=0` at `0x483d82` — so a `runOnUp` command
|
||||
// runs both halves in the same frame and a plain one runs its single half (the up leg is the
|
||||
// `RunCommand 0x4b7b50` no-op: `UP + !runOnUp` returns without running anything). Before this
|
||||
// the notch was a press with no release, which quietly made every press+release command a
|
||||
// dead wheel binding. Over UI the wheel belongs to the hovered frame.
|
||||
// Trackpads report pixel deltas — normalized to line-equivalents so the zoom consumer's
|
||||
// feel is unchanged from when it read the scroll itself.
|
||||
let wheel = match scroll.unit {
|
||||
bevy::input::mouse::MouseScrollUnit::Line => scroll.delta.y,
|
||||
bevy::input::mouse::MouseScrollUnit::Pixel => {
|
||||
scroll.delta.y / bevy::input::mouse::MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR
|
||||
}
|
||||
};
|
||||
// Trackpads report pixel deltas — normalized to line-equivalents ([`wheel_lines`]) so the
|
||||
// zoom consumer's feel is unchanged from when it read the scroll itself.
|
||||
let wheel = wheel_lines(scroll.unit, scroll.delta.y);
|
||||
// **Over CHROME**, not over any UI at all: the wheel still zooms with the cursor on a
|
||||
// nameplate, which is a mouse-enabled widget and not a panel (`PointerOverUiPanel`). Plates sit
|
||||
// over exactly the things you look at, so the raw flag silently killed scroll-zoom wherever one
|
||||
@@ -891,9 +930,10 @@ fn release(
|
||||
/// **Restoring it is not tidiness.** `keystate` is absent from the 1.12.1 client's in-world `_G`
|
||||
/// (`reference/1.12-globals.tsv`) even though its own `Bindings.xml` bodies read it as a bare
|
||||
/// global — which is only possible if the reference sets it transiently around the call, exactly
|
||||
/// as it does `this`/`event`/`arg1` (`invoke_with_globals`, RF-0025). Leaving it set would hand
|
||||
/// every addon a global the reference does not have, and an addon that feature-detects it would
|
||||
/// take a path we cannot honour — decision 1189's "a superset is not free", one call deeper.
|
||||
/// as it does `this`/`event`/`arg1` (`invoke_with_globals`; `0x703f50` → `0x704f10`). Leaving it
|
||||
/// set would hand every addon a global the reference does not have, and an addon that
|
||||
/// feature-detects it would take a path we cannot honour — decision 1189's
|
||||
/// "a superset is not free", one call deeper.
|
||||
///
|
||||
/// Save-and-restore rather than set-and-delete, because these bodies nest: a binding whose Lua
|
||||
/// fires another binding must not clear the outer one's `keystate` on the way out.
|
||||
@@ -1119,10 +1159,9 @@ mod tests {
|
||||
/// strata walk carries the key down to `CGWorldFrame`, and `ExecuteBinding` runs `TURNLEFT`.
|
||||
/// The reference's own chat box ships the flag, so this is the default experience.
|
||||
///
|
||||
/// benilla read the flag as "consumed but inert, unless Ctrl" until the §5
|
||||
/// (`ignorearrows-alt-arrow-gate.md`) corrected both halves — the modifier is ALT, and the key
|
||||
/// is not consumed at all. Under the old reading, holding LEFT with the chat box open did
|
||||
/// nothing whatever.
|
||||
/// benilla read the flag as "consumed but inert, unless Ctrl" until the bytes corrected both
|
||||
/// halves — the modifier is ALT, and the key is not consumed at all. Under the old reading,
|
||||
/// holding LEFT with the chat box open did nothing whatever.
|
||||
#[test]
|
||||
fn a_flagged_editbox_lets_the_arrow_keys_through_to_their_bindings() {
|
||||
let mut app = harness();
|
||||
@@ -1272,6 +1311,7 @@ mod tests {
|
||||
/// tests carry no page — between them the join was never asserted.
|
||||
#[test]
|
||||
fn a_wheel_notch_binds_through_the_real_page_and_then_dispatches() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let by_name =
|
||||
|n: &str| Cmd(SPECS.iter().position(|s| s.name == n).expect("registered") as u16);
|
||||
let mut s = crate::ui_script::keybindings_tests::harness();
|
||||
|
||||
@@ -176,8 +176,8 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
|
||||
// **On a Mac keyboard the print-screen key IS F13 — read out of the Mac binary now, not
|
||||
// inferred.** The 1.12.1 Mac slice's key table `0x5bf320` maps Mac virtual keycode `0x69`
|
||||
// (F13) to `0x212`, the same `PRINTSCREEN` the Windows table reaches from `VK_SNAPSHOT`.
|
||||
// (This arm shipped as an inference off `KEY_PRINTSCREEN_MAC = "F13"`; wow-re's
|
||||
// `keycode-origin-law.md` settled it at the bytes.) macOS has no PrintScreen keycode at
|
||||
// (This arm shipped as an inference off `KEY_PRINTSCREEN_MAC = "F13"`; the Mac table
|
||||
// settled it at the bytes.) macOS has no PrintScreen keycode at
|
||||
// all, so without this the byte-real `PRINTSCREEN SCREENSHOT` default is a dead key on
|
||||
// every Mac — not fidelity, a broken key.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -252,10 +252,10 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
|
||||
NumpadEqual => "NUMPADEQUALS",
|
||||
NumLock => "NUMLOCK",
|
||||
PrintScreen => "PRINTSCREEN",
|
||||
// ScrollLock and Pause are deliberately absent: the reference's namer calls their key
|
||||
// codes (`0x210`/`0x211`) `UNKNOWN`, so they are unbindable there (wow-re
|
||||
// `keybinding-dispatch-law.md` §2.3), and `IsValidBindingKeyString` would refuse the
|
||||
// names anyway — arm 4's 26-name table holds neither.
|
||||
// ScrollLock and Pause are deliberately absent: the reference's namer (`0x4b66b0`) calls
|
||||
// their key codes (`0x210`/`0x211`) `UNKNOWN`, so they are unbindable there, and
|
||||
// `IsValidBindingKeyString` (`0x4b7890`) would refuse the names anyway — its 26-name table
|
||||
// holds neither.
|
||||
CapsLock => "CAPSLOCK",
|
||||
Minus => "-",
|
||||
Equal => "=",
|
||||
|
||||
@@ -1149,10 +1149,10 @@ const TABLE: &[Spec] = &[
|
||||
// shot's confirmation, then ask the engine.
|
||||
//
|
||||
// `PRINTSCREEN` comes from the SHIPPED default, not a player's cache: `DefaultBindings.wtf`
|
||||
// lives inside `patch.MPQ` and its line 128 is `bind PRINTSCREEN SCREENSHOT` (wow-re's
|
||||
// dispatch on this feature; the install's account-ONE `bindings-cache.wtf` agrees). `Edge`,
|
||||
// not `EdgeUpDown`, is also byte-real: the `<Binding>` carries no `runOnUp`, and the
|
||||
// reference's dispatcher returns on key-up unless that flag is set (`0x4b7bea`).
|
||||
// lives inside `patch.MPQ` and its line 128 is `bind PRINTSCREEN SCREENSHOT` (the install's
|
||||
// account-ONE `bindings-cache.wtf` agrees). `Edge`, not `EdgeUpDown`, is also byte-real: the
|
||||
// `<Binding>` carries no `runOnUp`, and the reference's dispatcher returns on key-up unless
|
||||
// that flag is set (`0x4b7bea`).
|
||||
//
|
||||
// On a Mac keyboard the token arrives as F13, which is the reference's own Mac mapping rather
|
||||
// than an accommodation (`KEY_PRINTSCREEN_MAC = "F13"`); `super::chord` does the translation.
|
||||
@@ -2219,6 +2219,7 @@ mod tests {
|
||||
/// here as the witness for it.
|
||||
#[test]
|
||||
fn the_global_scanner_sees_both_kinds_of_definition() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let defined = lua_globals_defined();
|
||||
for host in ["TargetUnit", "UseAction", "SetBinding"] {
|
||||
assert!(
|
||||
@@ -2252,6 +2253,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The parser's own header, read against the file it describes. `benilla_ui::bindings_xml`
|
||||
/// quotes the shape of `Interface\FrameXML\Bindings.xml` — 228 live bindings, 94 `runOnUp`,
|
||||
/// 13 `header`, 12 `hidden`, the commented-out `MOVEVIEW*` family that a text search counts and
|
||||
/// a parser must not — and until 2331 the test that pinned those numbers lived beside the
|
||||
/// parser, keyed on a `BENILLA_BINDINGS_XML` path nothing ever set, so it had never run. It
|
||||
/// lives here because here is where the install is (`install_bindings`, off the player's own
|
||||
/// chain); a wrong count is either a wrong file or a header that drifted from the client.
|
||||
#[test]
|
||||
fn the_installs_bindings_xml_reads_as_the_parsers_header_says() {
|
||||
let Some(binds) = install_bindings() else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(
|
||||
binds.len(),
|
||||
228,
|
||||
"1.12.1 ships 228 live bindings — the other six are inside a comment"
|
||||
);
|
||||
assert!(binds.iter().all(|b| !b.name.is_empty()));
|
||||
assert!(
|
||||
!binds.iter().any(|b| b.name.starts_with("MOVEVIEW")),
|
||||
"the commented-out family must not register"
|
||||
);
|
||||
assert_eq!(binds.iter().filter(|b| b.run_on_up).count(), 94);
|
||||
assert_eq!(binds.iter().filter(|b| b.header.is_some()).count(), 13);
|
||||
assert_eq!(binds.iter().filter(|b| b.hidden).count(), 12);
|
||||
assert_eq!(
|
||||
binds[0].header.as_deref(),
|
||||
Some("MOVEMENT"),
|
||||
"the file opens on the MOVEMENT section"
|
||||
);
|
||||
}
|
||||
|
||||
/// The install's own `Bindings.xml`, parsed — `None` (and a skipped test) without a client.
|
||||
fn install_bindings() -> Option<Vec<benilla_ui::bindings_xml::AddonBinding>> {
|
||||
let data = benilla_formats::wow_data_or_skip!(None);
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
//! creature), the reference's per-frame shadow pass rebuilt on the shared surface-decal projector
|
||||
//! ([`benilla_world::decal`]), drawn on the shared effect stream (0733).
|
||||
//!
|
||||
//! **The byte-verified mechanism** (wow-re `unit-blob-shadow.md`, a §5 cross-check; the "cloud
|
||||
//! shadow" label on `0x6d7920` was corrected — it IS the unit shadow draw):
|
||||
//! **The mechanism** (`0x6d7920` IS the unit shadow draw):
|
||||
//! - **Draw path**: a per-frame pass over registered model nodes (`0x683dd0`, list `[0xc7cb10]`)
|
||||
//! → gate `0x6d78f0` (model streamed, master toggle) → `0x6d7920` → the **same decal chain the
|
||||
//! selection ring uses** (`0x6d7330 → 0x6d6fa0 → 0x6d7480`), collector flags `0x2f0122` = the
|
||||
@@ -11,15 +10,12 @@
|
||||
//! [`GroundDecalSurface`] set yet — the shadow lands on terrain + WMO faces only).
|
||||
//! - **Frame slot**: PHASE 1, among the opaque drains — `0x6812c5 call 0x683dd0`, fifth of the
|
||||
//! row `0x6812b1`–`0x6812ca` inside `0x681070`, which the driver `0x483460` calls at
|
||||
//! `0x48361d` (wow-re `water-frame-straddle.md` §1 + `unit-blob-shadow.md` Q1). `0x683dd0` is
|
||||
//! the **M2 node drain**, and the same loop body ticks each node's object first
|
||||
//! (`0x48160c call [obj vt+0x38]` → the selection ring) and draws its shadow second
|
||||
//! (`0x683ec3`) — so per unit the additive ring goes down and this modulate darkens it, never
|
||||
//! the other way round (wow-re `decal-frame-slot.md`). That second step is **not** a gate:
|
||||
//! both exits of the tick `0x481540` return 1, so `0x683ea5`'s `je` is dead — an earlier
|
||||
//! reading of it as "the callback suppresses a hidden object's shadow" was wrong at the bytes
|
||||
//! and is corrected in wow-re (2026-09-06). What the tick DOES do is write the alpha this draw
|
||||
//! is about to read, 30 bytes later — which is the whole of §"Appearance" below. So the shadow
|
||||
//! `0x48361d`. `0x683dd0` is the **M2 node drain**, and the same loop body ticks each node's
|
||||
//! object first (`0x48160c call [obj vt+0x38]` → the selection ring) and draws its shadow second
|
||||
//! (`0x683ec3`) — so per unit the additive ring goes down and this modulate darkens it, never the
|
||||
//! other way round. That second step is **not** a gate: both exits of the tick `0x481540` return
|
||||
//! 1, so `0x683ea5`'s `je` is dead. What the tick DOES do is write the alpha this draw is about
|
||||
//! to read, 30 bytes later — which is the whole of §"Appearance" below. So the shadow
|
||||
//! lands after terrain and WMO and **before** everything else: the footprint decals
|
||||
//! (`0x483654`), the M2 opaque pass (`0x4836a6`), the water surfaces (phase 3, drawn *between*
|
||||
//! the two M2 transparent passes) and both of those passes. Every transparent in the world
|
||||
@@ -29,17 +25,17 @@
|
||||
//! - **Texture**: `Textures\ShadowBlob.blp` — a 32×32 grayscale radial blob (flat gray-160 core,
|
||||
//! linear rim to white) under a binary alpha disc. The reference multitextures a procedural 64×8
|
||||
//! trapezoid ramp on a second stage (`0x6d81a0`/`0x6d82d0`, blend-mode-selected); its combine
|
||||
//! wiring is an open RE item (apitrace) — here the ramp is the vertex-alpha vertical fade below.
|
||||
//! wiring is open (an apitrace question) — here the ramp is the vertex-alpha vertical fade below.
|
||||
//! - **Box law** (`0x711a20` + the `0x6d7920` corner build): a sequence CAaBox, clamped INTO ±5
|
||||
//! per axis (a cap, never a floor), scaled by the world matrix, yaw-rotated with the unit's
|
||||
//! facing then **axis-aligned-bounded**. Vertical about the model origin: `+1.0·(zExt/2)` up,
|
||||
//! `−(5/3)·(zExt/2)` down. A degenerate horizontal box is the reference's no-op exit (no
|
||||
//! shadow). **No** `OBJECT_FIELD_SCALE_X` re-read (the transform scale already carries it), no
|
||||
//! ring-style `sqrt` compression, no floor. **WHICH sequence — settled at bytes + pixels**
|
||||
//! (decision 0316; wow-re `27406d9b`, Q3-ORACLE): the draw re-reads
|
||||
//! (decision 0316): the draw re-reads
|
||||
//! `playableAnimationLookup[0]` every frame — **slot 0 = Stand for characters, from the file
|
||||
//! image, so the value never changes** (not the playing sequence: the director's gait-stable
|
||||
//! observation falsified that first reading, and the trace oracle confirmed — 1,682 measured
|
||||
//! observation falsified that first reading, and reference captures confirmed — 1,682 measured
|
||||
//! draws, six bit-stable box sizes, HumanMale 0.9134 × 1.0805 yd permanently, Walk/Run extents
|
||||
//! never appear). Full extents, no missing half/scale factor — the standing size IS the law.
|
||||
//! - **Appearance**: multiplicative darken — `GL_DST_COLOR/GL_ZERO` with the fade riding the
|
||||
@@ -51,7 +47,7 @@
|
||||
//! touched at exactly three `mov ecx,ebx; call` sites (box `0x711a20`, matrix `0x710600`,
|
||||
//! alpha `0x710ca0`), which makes that list the complete field census.
|
||||
//!
|
||||
//! **What rides in, at the bytes** (wow-re, 2026-09-06): the 2 s appear ramp and the despawn
|
||||
//! **What rides in, at the bytes** (2026-09-06): the 2 s appear ramp and the despawn
|
||||
//! ramp (`obj+0xf4`, `0x613b1e`'s `0x7d0`), the self first-person fade, **and the CharProc-14
|
||||
//! aura transition** — stealth's `0.3`, ghost/invisibility's `0.5`. The aura is not a second
|
||||
//! channel: `0x60d180` drives the SAME `StartAlphaFade` (`0x614f80`) the appear fade uses, and
|
||||
@@ -70,7 +66,7 @@
|
||||
//! - **Gating**: the reference's `shadowLOD` cvar {0,1} is the master toggle (default on) — we are
|
||||
//! always-on; `shadowBias` (default 0.1) is its depth-bias knob — [`SHADOW_DEPTH_BIAS`] plays
|
||||
//! that role here. No dead/mount/kind test exists on the draw path, and **which** objects
|
||||
//! register is now settled (wow-re, 2026-09-06 — the old `HANDOFF(-> object-layer)` is closed):
|
||||
//! register is now settled (2026-09-06):
|
||||
//! `[node+0x90]` bit `0x400` is `NOT(arg bit1)` (`0x670e94`), and `0x613e10` takes that arg off
|
||||
//! `OBJECT_FIELD_TYPE` — `0xb` GAMEOBJECT, `2` DYNAMICOBJECT, `0` otherwise. **GameObjects and
|
||||
//! DynamicObjects never cast a blob shadow; units, players and corpses always do.** Which is
|
||||
@@ -108,7 +104,7 @@ use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
|
||||
use benilla_world::view::WorldCamera;
|
||||
use benilla_world::wmo_portal::UnitWmoRoom;
|
||||
|
||||
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, wow-re unit-blob-shadow RE): grayscale
|
||||
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, created by `0x6d8070`): grayscale
|
||||
/// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground.
|
||||
const SHADOW_TEXTURE: &str = "mpq://textures/shadowblob.blp";
|
||||
/// The byte clamp on the animation box: each corner component is clamped INTO ±5 yd pre-scale
|
||||
@@ -183,8 +179,8 @@ fn setup_shadow_assets(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
|
||||
/// Keep one shadow record per eligible unit: spawn for new Player/Unit entities whose model has
|
||||
/// built (an animated model — [`ModelAnimations`] arrives with it), despawn orphans (owner
|
||||
/// destroyed / streamed out). The registration *policy* is the open RE item; this is the v1 set
|
||||
/// (see module docs).
|
||||
/// destroyed / streamed out). The registration *policy* is still open; this is the v1 set (see
|
||||
/// module docs).
|
||||
#[allow(clippy::type_complexity)] // the filtered spawn-gate query, commented inline
|
||||
fn sync_shadows(
|
||||
mut commands: Commands,
|
||||
@@ -277,7 +273,7 @@ fn update_shadows(
|
||||
// mount's rendered scale while a mount model is attached (the mount IS the footprint on the
|
||||
// ground; the rider's box would undersize it). The mount-vs-body source of the client's own
|
||||
// shadow box is untraced — this is the named approximation of decision 0441's P2, carried
|
||||
// until a wow-re shadow-consumer trace pins it.
|
||||
// until that source is known.
|
||||
mount_anims: Query<(&NetEntity, &ModelAnimations), With<crate::entities::mount::MountBody>>,
|
||||
mut shadows: Query<(&BlobShadow, &mut ShadowKey, &mut ShadowVerts)>,
|
||||
// Once-a-second census at debug level (`RUST_LOG=benilla_app::blob_shadow=debug` — the lib
|
||||
@@ -351,7 +347,7 @@ fn update_shadows(
|
||||
Some((mnet, manims)) => (manims, mnet.scale),
|
||||
None => (anims, 1.0),
|
||||
};
|
||||
// The byte+pixel law (0316, wow-re 27406d9b): the box is playableAnimationLookup[0]'s
|
||||
// The byte+pixel law (0316): the box is playableAnimationLookup[0]'s
|
||||
// sequence — Stand, permanently (the reference re-reads it per frame from the file image;
|
||||
// the value can't change). resolve(0) walks the same baked table, so Stand-less models
|
||||
// land on their substitute exactly like the binary's row-0 fast path.
|
||||
@@ -521,8 +517,9 @@ fn update_shadows(
|
||||
.map(|v| format!("({:.3},{:.3})", v.uv[0], v.uv[1]))
|
||||
.collect();
|
||||
debug!("self shadow uvs: {}", uvs.join(" "));
|
||||
// And the box/rect numbers: the oracle says HumanFemale's footprint is 0.77x0.74 yd
|
||||
// nearly centred; a bigger or offset rect indicts the box math, not the projector.
|
||||
// And the box/rect numbers: measured on the reference client, HumanFemale's
|
||||
// footprint is 0.77x0.74 yd nearly centred; a bigger or offset rect indicts the box
|
||||
// math, not the projector.
|
||||
debug!(
|
||||
"self shadow box: bmin {:?} bmax {:?} rect x [{:.3}, {:.3}] z [{:.3}, {:.3}] \
|
||||
(extent {:.3}x{:.3}, centre offset ({:.3}, {:.3}))",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//! The engine-drawn **bowstring** (wow-re `nocked-ammo-cancel.md` §G2, byte-verified): bow M2s
|
||||
//! carry NO string geometry — the real client registers a bow-only per-frame draw callback
|
||||
//! (`0x611ff0`) that spans the bow's `$WTT`/`$WTB` limb-tip event markers with a **2-segment
|
||||
//! line list**, middle vertex at the character's HandArrow attach while the nock latch
|
||||
//! (`[+0xd58] & 0x4000`) holds, else the tip midpoint — so the string tracks the draw hand while
|
||||
//! nocked and relaxes to a straight chord at rest.
|
||||
//! The engine-drawn **bowstring**: bow M2s carry NO string geometry — the real client registers a
|
||||
//! bow-only per-frame draw callback (`0x611ff0`) that spans the bow's `$WTT`/`$WTB` limb-tip event
|
||||
//! markers with a **2-segment line list**, middle vertex at the character's HandArrow attach while
|
||||
//! the nock latch (`[+0xd58] & 0x4000`) holds, else the tip midpoint — so the string tracks the
|
||||
//! draw hand while nocked and relaxes to a straight chord at rest.
|
||||
//!
|
||||
//! Benilla's transcription draws the two segments through Bevy's gizmo lines — the same
|
||||
//! immediate-mode screen-space-width primitive class as the client's GX lines. Named
|
||||
//! deviation (decision record): the string color/width are INFERRED (the callback emits a
|
||||
//! packed vertex color the round didn't decode — a dark cord is used).
|
||||
//! deviation (decision record): the string color/width are inferred (the callback emits a
|
||||
//! packed vertex color that is not decoded — a dark cord is used).
|
||||
//!
|
||||
//! **The tips are POSED** (decision 2281). The bow prop animates — `$BWP` arms BowPull(160) on the
|
||||
//! prop's own model and `$BWR` returns it to Stand(0) — so the `$WTT`/`$WTB` markers ride limb
|
||||
@@ -21,7 +20,7 @@ use bevy::prelude::*;
|
||||
use crate::creature_anim::NockLatch;
|
||||
use crate::entities::BoneAttach;
|
||||
|
||||
/// The HandArrow attach id (35 — wow-re §E2/G2): the string's middle control point while nocked,
|
||||
/// The HandArrow attach id (35 — `0x6121b8`): the string's middle control point while nocked,
|
||||
/// the same point the nocked-arrow model rides.
|
||||
const HAND_ARROW: u16 = 0x23;
|
||||
|
||||
@@ -55,7 +54,7 @@ fn draw_bowstrings(
|
||||
joints: Query<&GlobalTransform>,
|
||||
mut gizmos: Gizmos,
|
||||
) {
|
||||
// A dark waxed-cord tone; the ref's packed vertex color is not decoded (INFERRED, §G2).
|
||||
// A dark waxed-cord tone, inferred: the ref's packed vertex color is not decoded.
|
||||
const STRING_COLOR: Color = Color::srgb(0.12, 0.10, 0.08);
|
||||
for (bs, prop, vis, flex) in &bows {
|
||||
if !vis.get() {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//!
|
||||
//! **Four producers, two id spaces, one evaluator.** Every producer ends at the same spawner —
|
||||
//! `AddShake(id, worldPos)` (`0x511d40`), whose five call sites are the whole population (proven by
|
||||
//! xref sweep, wow-re `spell/scratch/camera-shake-producers.md` §Q4: no address-dword reference to
|
||||
//! it anywhere, and no Lua, console, cinematic or quake path).
|
||||
//! xref sweep: no address-dword reference to it anywhere, and no Lua, console, cinematic or quake
|
||||
//! path).
|
||||
//!
|
||||
//! The **creature** pair names a `CameraShakes.dbc` preset **directly**:
|
||||
//!
|
||||
@@ -33,7 +33,7 @@
|
||||
//! thumping-giant list; 49 carry one of the two, and 58 of the 1772 kits carry a shake group. See
|
||||
//! `benilla-extract shakecensus`, which maps all four producers onto the 24 presets.
|
||||
//!
|
||||
//! ## The law (wow-re `ui/scratch/camera-shake-law.md`, §5-verified)
|
||||
//! ## The law
|
||||
//!
|
||||
//! **Emission — the footstep**, gated in this order (`0x5fbf70`): not hovering · `BYTES_1` byte 3
|
||||
//! bit `0x2` clear (our stealth bit) · not a player-ghost · **`|camera − footplant|² ≤ 2500`
|
||||
@@ -171,7 +171,7 @@ impl CameraShakes {
|
||||
/// Retire what has expired and compose this frame's offset.
|
||||
///
|
||||
/// `suspended` is the reference's skip — **two** gates that both jump the same block
|
||||
/// (`0x50ea87` / `0x50ea8b` → `0x50eb01`, wow-re `camera-shake-law.md` §6): the followed unit
|
||||
/// (`0x50ea87` / `0x50ea8b` → `0x50eb01`): the followed unit
|
||||
/// is **swimming** (`[[unit+0x118]+0x40] & 0x200000`), or it is riding a **flying server
|
||||
/// spline** (`MI = [[unit+0x118]+0xa4]` non-null, not done, Flying bit set) — a taxi flight.
|
||||
/// The accumulator is zeroed *before* both gates, so a suspended frame yields the offset zero
|
||||
@@ -221,7 +221,7 @@ impl LiveShake {
|
||||
/// This record's `(compare key, signed offset)` at `eye`, or `None` when out of cull range.
|
||||
///
|
||||
/// The compare key is the **distance-attenuated amplitude** — a per-record quantity that does
|
||||
/// not swing with the sine — per wow-re's reading of the accumulator's stored slot.
|
||||
/// not swing with the sine — the stored slot the reference compares at `0x5118a5`.
|
||||
fn sample(&self, eye: Vec3, now: f32) -> Option<(f32, f32)> {
|
||||
let d2 = eye.distance_squared(self.pos);
|
||||
if d2 > CULL_DISTANCE_SQ {
|
||||
@@ -404,7 +404,7 @@ fn fire_shakes(
|
||||
/// Charge (1424). That is a read-before-write of a zero-initialised field, not a design — the
|
||||
/// value it wants is unambiguous, being the same one the sound leg uses a moment later — so we
|
||||
/// spawn at the kit play's real position and the authored data plays. benilla implements the
|
||||
/// mechanism, not the quirk (the contract §3, `method.md` step 3); the reference's own numbers are
|
||||
/// mechanism, not the quirk (`docs/METHOD.md` §3, `docs/METHOD.md` step 3); the reference's own numbers are
|
||||
/// in `decisions/1849`, and reverting to them would mean deliberately modelling an uninitialised
|
||||
/// field we do not have.
|
||||
fn fire_kit_shakes(
|
||||
@@ -451,14 +451,14 @@ type FollowedUnit = (
|
||||
/// **Swimming** is its move flags (`[[unit+0x118]+0x40] & 0x200000`).
|
||||
///
|
||||
/// **The spline gate** is read through to the LIVE path, exactly as the anim selector's `unify`
|
||||
/// does ([`crate::creature_anim`], RF-0057 `0x5fd19c`) — the reference re-reads
|
||||
/// does ([`crate::creature_anim`], `0x5fd19c`) — the reference re-reads
|
||||
/// `[[unit+0x118]+0xa4]` every frame rather than trusting a stamped flag, and our stored
|
||||
/// [`MovementState::flying`] is the selector's own derived value, left `false` on the controller's
|
||||
/// component. The [`Spline`]'s *presence* is the reference's "descriptor non-null **and** not done"
|
||||
/// (`sample_splines` removes the component the frame the path completes — the `& 0x4` DONE term),
|
||||
/// and `!grounded` is spline flag `0x200`.
|
||||
///
|
||||
/// **`0x200` is the shared fly-*or*-swim spline bit, not "taxi"** (wow-re `swim-transition.md`) —
|
||||
/// **`0x200` is the shared fly-*or*-swim spline bit, not "taxi"** (`0x616cb0`) —
|
||||
/// so this arm covers a swimming server path as well as a flight, which is the same reason it
|
||||
/// sits beside a swim gate at all. Naming it "the taxi arm" (as 1540 did, and as this was first
|
||||
/// built) over-specifies what the bit means. A **ground** spline — Charge, knockback, a fleeing
|
||||
|
||||
@@ -200,7 +200,8 @@ struct QuadProbe {
|
||||
corners: [Vec2; 4],
|
||||
/// The NDC depth the corners carry. Read from the projected corners, not assumed constant —
|
||||
/// the *spread* is reported, because a billboard that is not a constant-depth plane is a
|
||||
/// different bug wearing the same symptom (wow-re `part-flush-emitter-depth.md` §4).
|
||||
/// different bug wearing the same symptom (`0x7b2a50`'s plain billboard gives all four
|
||||
/// corners one depth).
|
||||
dquad: f32,
|
||||
dspread: f32,
|
||||
/// The quad centre's distance to the camera plane, yards — the unit the burial is reported in.
|
||||
|
||||
@@ -42,7 +42,9 @@ pub(super) fn seed_ui_fixture(
|
||||
mut quest: ResMut<crate::ui_quest::QuestGiver>,
|
||||
mut quest_log: ResMut<crate::ui_quest_log::QuestLog>,
|
||||
mut loot: ResMut<crate::ui_loot::LootState>,
|
||||
mut items: ResMut<crate::items::Items>,
|
||||
// The item store and the object index as one param (the 16-SystemParam ceiling): the item
|
||||
// objects are entities in the index (2334), and the fixture spawns them like the wire does.
|
||||
store: (ResMut<crate::items::Items>, ResMut<crate::net::GuidIndex>),
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
icons: Option<Res<crate::entities::ItemDisplays>>,
|
||||
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
@@ -57,6 +59,7 @@ pub(super) fn seed_ui_fixture(
|
||||
ResMut<crate::loading_screen::LoadingScreen>,
|
||||
),
|
||||
) {
|
||||
let (mut items, mut index) = store;
|
||||
// A glue-screen capture has no world scenario, and no glue screen opens a UI fixture.
|
||||
let Some(scenario) = ctx.scenario else {
|
||||
return;
|
||||
@@ -72,7 +75,7 @@ pub(super) fn seed_ui_fixture(
|
||||
// **A UI capture with no script VM is not a capture — refuse it.** Every seed below opens its
|
||||
// window by calling into the in-game UI, so with no VM they all fail the same way: a nil
|
||||
// global, one `warn!` in a log full of pipeline chatter, a valid-looking PNG of a UI-less
|
||||
// world, and exit 0. That is the false-negative shape `method.md` §6 exists to prevent, and it
|
||||
// world, and exit 0. That is the false-negative shape `docs/METHOD.md` §6 exists to prevent, and it
|
||||
// burned a session. `scenario_wants_ui` removed the cause (a `ui:` scenario no longer needs
|
||||
// `WOW_CAPTURE_UI=1`); this is the tripwire for whatever else could leave the VM absent, and
|
||||
// it exits non-zero the way the window-size refusal does (`video::warn_if_window_mismatch`).
|
||||
@@ -326,7 +329,7 @@ pub(super) fn seed_ui_fixture(
|
||||
(G_STONE, 94_004, 1, "Hearthstone", DISP_STONE, 1),
|
||||
(G_BANKBAG, 94_005, 1, "Small Brown Pouch", DISP_STONE, 1),
|
||||
] {
|
||||
items.insert_object(guid, obj(entry, stack));
|
||||
crate::items::spawn_item(&mut commands, &mut index, guid, obj(entry, stack), false);
|
||||
let mut t = template(name, quality);
|
||||
t.display_info_id = disp;
|
||||
if guid == G_BANKBAG {
|
||||
@@ -338,7 +341,9 @@ pub(super) fn seed_ui_fixture(
|
||||
// The held bank bag is a real CONTAINER object (its contents stream on the bag item —
|
||||
// decision 0604): 6 slots, one occupied, so the POPOUT window (container 5) is in the
|
||||
// shot too — its snug-fit stitch, lit bag button, and own-icon portrait.
|
||||
items.insert_object(
|
||||
crate::items::spawn_item(
|
||||
&mut commands,
|
||||
&mut index,
|
||||
G_BANKBAG,
|
||||
ObjectFields::from_pairs(&[
|
||||
(3, 94_005),
|
||||
@@ -346,6 +351,7 @@ pub(super) fn seed_ui_fixture(
|
||||
(48, 6), // CONTAINER_NUM_SLOTS
|
||||
(50, G_JERKY as u32), // CONTAINER_SLOT_1
|
||||
]),
|
||||
true,
|
||||
);
|
||||
// The self player: 4 occupied vault slots, the bank bag, TWO bought bag slots
|
||||
// (`PLAYER_BYTES_2` byte 2 — bag button 1 owned, 2 bought-but-empty, 3–6 the red
|
||||
@@ -717,7 +723,7 @@ pub(super) fn seed_ui_fixture(
|
||||
// Raised through the real registry entry, not a hand-built frame: the text comes from
|
||||
// the chain's own `INVITATION` GlobalString and the two buttons from ACCEPT/DECLINE,
|
||||
// so the capture exercises the same Show path a real invite takes.
|
||||
if let Err(e) = script.run(r#"StaticPopup_Show("PARTY_INVITE", "Thalyn")"#) {
|
||||
if let Err(e) = script.run(r#"StaticPopup_Show("PARTY_INVITE", "Brisca")"#) {
|
||||
warn!("capture: ui-partyinvite seed failed to raise the dialog: {e}");
|
||||
}
|
||||
}
|
||||
@@ -765,8 +771,8 @@ pub(super) fn seed_ui_fixture(
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
// The §22 SET block (real Defias Leather shape, 5 members, one equipped): gold
|
||||
// "(1/5)" header + spacer, cream/gray member ladder, green (2)-bonus vs gray
|
||||
// The item-SET block (`0x52b650`; real Defias Leather shape, 5 members, one equipped):
|
||||
// gold "(1/5)" header + spacer, cream/gray member ladder, green (2)-bonus vs gray
|
||||
// (4)-bonus — the whole block's visual regression instrument.
|
||||
let mut inv: benilla_ui::script::InventorySlots = Default::default();
|
||||
inv[4] = Some(benilla_ui::script::InvSlotView {
|
||||
@@ -911,10 +917,10 @@ pub(super) fn seed_ui_fixture(
|
||||
let obj = |entry: u32, stack: u32| {
|
||||
ObjectFields::from_pairs(&[(3, entry), (14, stack)]) // OBJECT_ENTRY, STACK_COUNT
|
||||
};
|
||||
items.insert_object(G_CHEST, obj(93_010, 1));
|
||||
items.insert_object(G_SWORD, obj(93_011, 1));
|
||||
items.insert_object(G_BOW, obj(93_013, 1));
|
||||
items.insert_object(G_ARROWS, obj(93_012, 200));
|
||||
crate::items::spawn_item(&mut commands, &mut index, G_CHEST, obj(93_010, 1), false);
|
||||
crate::items::spawn_item(&mut commands, &mut index, G_SWORD, obj(93_011, 1), false);
|
||||
crate::items::spawn_item(&mut commands, &mut index, G_BOW, obj(93_013, 1), false);
|
||||
crate::items::spawn_item(&mut commands, &mut index, G_ARROWS, obj(93_012, 200), false);
|
||||
let mut chest = template("Tarnished Chainmail", 1);
|
||||
chest.display_info_id = DISP_SHIELD;
|
||||
chest.inventory_type = 5;
|
||||
@@ -1181,7 +1187,13 @@ pub(super) fn seed_ui_fixture(
|
||||
crate::net::Guid(PLAYER_GUID),
|
||||
));
|
||||
// The equipped main-hand weapon — the auto-attack borrows its icon (decision 0230).
|
||||
items.insert_object(G_SWORD, ObjectFields::from_pairs(&[(3, 93_011), (14, 1)]));
|
||||
crate::items::spawn_item(
|
||||
&mut commands,
|
||||
&mut index,
|
||||
G_SWORD,
|
||||
ObjectFields::from_pairs(&[(3, 93_011), (14, 1)]),
|
||||
false,
|
||||
);
|
||||
let mut sword = template("Militia Shortsword", 2);
|
||||
sword.display_info_id = DISP_SWORD;
|
||||
items.insert_template(93_011, Some(sword));
|
||||
|
||||
@@ -97,8 +97,8 @@ pub(crate) fn drive_fx_view(
|
||||
.id();
|
||||
}
|
||||
// `WOW_FX_GO`: the GAMEOBJECT lane. A placed trap/door/chest reaches the screen through
|
||||
// `crate::go_anim`'s §243 state machine, never through the effect pool, so this is the
|
||||
// only lane that reproduces one. The descriptor carries the three fields the machine
|
||||
// `crate::go_anim`'s state machine (`0x5f3cb0`), never through the effect pool, so this is
|
||||
// the only lane that reproduces one. The descriptor carries the three fields the machine
|
||||
// reads — display, TYPE_ID (the `go_animates` gate) and STATE (the substate) — and
|
||||
// nothing else, so an unset knob renders exactly what an omitted wire field renders.
|
||||
// Always seated on the terrain: a GO stands on the ground.
|
||||
|
||||
@@ -226,12 +226,12 @@ fn glue_screen() -> Option<GlueScreen> {
|
||||
///
|
||||
/// **`WOW_FX_GO=<GameObjectDisplayInfo id>` swaps the subject onto the GAMEOBJECT path** — the
|
||||
/// third lane, and the one a placed trap/door/chest actually takes: the wire component set a
|
||||
/// streamed GO gets, so `crate::go_anim`'s §243 state machine — not the effect pool — chooses the
|
||||
/// sequence. `WOW_FX_GO_STATE` (`GAMEOBJECT_STATE`, default 1 READY) and `WOW_FX_GO_TYPE`
|
||||
/// (`GAMEOBJECT_TYPE_ID`, default 6 TRAP) select the substate; `benilla-extract goanimscan`
|
||||
/// *predicts* what that resolves to, and this *shows* it. The lane matters because an absent
|
||||
/// `GAMEOBJECT_STATE` reads as the wire default `0` = ACTIVE, which on a model with no `Opened`
|
||||
/// sequence lands somewhere else entirely:
|
||||
/// streamed GO gets, so `crate::go_anim`'s state machine (`0x5f3cb0`) — not the effect pool —
|
||||
/// chooses the sequence. `WOW_FX_GO_STATE` (`GAMEOBJECT_STATE`, default 1 READY) and
|
||||
/// `WOW_FX_GO_TYPE` (`GAMEOBJECT_TYPE_ID`, default 6 TRAP) select the substate;
|
||||
/// `benilla-extract goanimscan` *predicts* what that resolves to, and this *shows* it. The lane
|
||||
/// matters because an absent `GAMEOBJECT_STATE` reads as the wire default `0` = ACTIVE, which on a
|
||||
/// model with no `Opened` sequence lands somewhere else entirely:
|
||||
///
|
||||
/// ```text
|
||||
/// WOW_DATA=<Data> WOW_CAPTURE=fxview WOW_FX_GO=3073 WOW_FX_GO_STATE=1 WOW_FX_AGE=4 \
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! is the step this probe exists for. The house id is checked to be in `1..=7` because it keys the
|
||||
//! deposit rate the sell pane quotes, and a zero would silently quote a free listing.
|
||||
//!
|
||||
//! ## The auctioneer (live-DB verified this session, `/Users/sam/dev/vmangos-deploy` → `mangos`)
|
||||
//! ## The auctioneer (live-DB verified against the local vmangos, `mangos`)
|
||||
//!
|
||||
//! Auctioneer Fitch, creature entry 8719, spawn guid 12696, map 0 (Stormwind, Trade District),
|
||||
//! pos `(-8821.53, 659.886, 97.4645)`. Her `creature_template.npc_flags` is **4096** exactly —
|
||||
@@ -57,8 +57,8 @@
|
||||
//! WOW_PROBE_AUCTION=1 WOW_NOSOUND=1 WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive \
|
||||
//! cargo run -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — `pool-N` → `WOW_USER=probeN WOW_PASS=pprobeN
|
||||
//! WOW_CHAR=Probe<N-spelled>`, method.md "The local vmangos server"; this worktree is `pool-5`).
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill).
|
||||
//! Non-combat. An outer grep on `PROBE_AUCTION:` is the whole harness; the probe self-exits (the
|
||||
//! [`super::probes::ProbeExitPlugin`] pattern) once DONE.
|
||||
|
||||
@@ -161,11 +161,20 @@ struct AuctionProbe {
|
||||
baseline_money: u32,
|
||||
/// `AUCTION_OWNED_LIST_UPDATE`'s count immediately before `StartAuction` went in.
|
||||
///
|
||||
/// Taken THERE and not when step (6) starts, because the STARTED result queues an owner
|
||||
/// re-query of its own: by the time (6) is reached the event may already have fired, and a
|
||||
/// baseline taken then would be waiting for a second one that a repeated identical page will
|
||||
/// never produce.
|
||||
/// Taken THERE and not when step (6) starts, so that an owner re-query queued by the STARTED
|
||||
/// result cannot fire the event before the baseline is read — a baseline taken after it would
|
||||
/// wait for a second event that a repeated identical page will never produce. (Since 2308 that
|
||||
/// re-query only happens for a list we already hold, and step 6 is what first asks for this
|
||||
/// one, so the ordering is belt and braces rather than load-bearing.)
|
||||
owned_event_baseline: i64,
|
||||
/// How many red `UI_ERROR_MESSAGE` lines the run had already raised when the auction window
|
||||
/// opened — the login environment's own, not the arc's.
|
||||
///
|
||||
/// Step (6d) asserts the arc raised **no** red line, and it used to read the whole tally: on a
|
||||
/// `probeN` account that is never zero, because the preflight's `GM mode is ON` and
|
||||
/// `GOD mode is ON` banners (0679) are red lines too, and they land before the greeting. The
|
||||
/// assertion was therefore FAILING on every clean run, which is worse than not making it.
|
||||
red_baseline: i64,
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
@@ -287,6 +296,13 @@ fn last_error(script: &UiScript) -> String {
|
||||
///
|
||||
/// Compared against `getglobal("ERR_AUCTION_STARTED")` rather than against English: the string is
|
||||
/// the player's own, and this file must not carry a copy of it.
|
||||
/// Red `UI_ERROR_MESSAGE` lines raised so far, as the hook's own array counts them.
|
||||
fn red_count(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("return table.getn(ProbeAuctionErrors)")
|
||||
.unwrap_or(-1)
|
||||
}
|
||||
|
||||
fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
let said = script
|
||||
.eval::<i64>(
|
||||
@@ -298,9 +314,7 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
return 0",
|
||||
)
|
||||
.unwrap_or(-2);
|
||||
let reds = script
|
||||
.eval::<i64>("return table.getn(ProbeAuctionErrors)")
|
||||
.unwrap_or(-1);
|
||||
let reds = red_count(script) - probe.red_baseline;
|
||||
match (said, reds) {
|
||||
(1, 0) => {
|
||||
info!(
|
||||
@@ -317,13 +331,16 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
probe.fails += 1;
|
||||
}
|
||||
(said, reds) => {
|
||||
// The reds are listed from the baseline on, for the same reason `reds` counts from
|
||||
// there: the login environment's own banners are not this arc's output.
|
||||
let lines = script
|
||||
.eval::<String>(
|
||||
"local t = {} \
|
||||
.eval::<String>(&format!(
|
||||
"local t = {{}} \
|
||||
for i = 1, table.getn(ProbeAuctionChat) do table.insert(t, ProbeAuctionChat[i]) end \
|
||||
for i = 1, table.getn(ProbeAuctionErrors) do table.insert(t, \"RED:\" .. ProbeAuctionErrors[i]) end \
|
||||
for i = {} + 1, table.getn(ProbeAuctionErrors) do table.insert(t, \"RED:\" .. ProbeAuctionErrors[i]) end \
|
||||
return table.concat(t, \" | \")",
|
||||
)
|
||||
probe.red_baseline.max(0)
|
||||
))
|
||||
.unwrap_or_default();
|
||||
error!(
|
||||
"PROBE_AUCTION: FAIL (6d chat) — wanted the STARTED line in chat and zero red \
|
||||
@@ -342,8 +359,15 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
/// coin. Derived from `probe.min_bid` rather than hardcoded, because the listing price is computed
|
||||
/// from the item's own vendor value and a fixed expectation would be a lie the day the item changes.
|
||||
///
|
||||
/// `IsShown`, not `IsVisible`: the Auctions pane is behind the Browse tab at this point in the run
|
||||
/// and the rows are painted either way — visibility would test the tab, not the coins.
|
||||
/// `IsShown`, not `IsVisible`: the coin buttons' own shown-ness is the subject, not whatever the
|
||||
/// window is doing around them.
|
||||
///
|
||||
/// The pane is the *front* tab by the time this runs, and that is load-bearing rather than
|
||||
/// incidental: the row is painted by `AuctionFrameAuctions_Update`, which cannot complete before
|
||||
/// the tab's `OnShow` has given it a `page` (decision 2308). This check read the player's purse
|
||||
/// out of the row's money frame for as long as the probe asked for the owned list without opening
|
||||
/// the tab — the frame was still on its `MoneyTypeInfo["PLAYER"]` default because nothing had ever
|
||||
/// repainted it.
|
||||
fn owner_money_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
let (gold, silver, copper) = (
|
||||
probe.min_bid / 10_000,
|
||||
@@ -601,6 +625,9 @@ fn auction_probe(
|
||||
auction.auctioneer.unwrap_or(0)
|
||||
);
|
||||
probe.passes += 1;
|
||||
// Everything red before this instant is the login environment (the GM/GOD
|
||||
// banners); the arc's own tally starts here.
|
||||
probe.red_baseline = red_count(&script);
|
||||
} else {
|
||||
error!(
|
||||
"PROBE_AUCTION: FAIL (2 greet) — the session opened, but on {:?} (we \
|
||||
@@ -1151,10 +1178,27 @@ fn auction_probe(
|
||||
probe.phase = cancel_at(now);
|
||||
return;
|
||||
}
|
||||
// Re-ask on a cadence: the server drops a second in-flight list request silently, and
|
||||
// the STARTED result has already queued a refresh of its own.
|
||||
// Re-ask on a cadence: the server drops a second in-flight list request silently.
|
||||
//
|
||||
// **The FIRST ask is the tab click, not the raw binding** (decision 2308). Every
|
||||
// caller of `GetOwnerAuctionItems` in the stock files lives inside the Auctions pane —
|
||||
// its `OnShow` and its two page turners — and the `OnShow` is also the one place that
|
||||
// assigns `AuctionFrameAuctions.page`. Calling the binding cold puts the window in a
|
||||
// state no click can reach, and the reply's `AUCTION_OWNED_LIST_UPDATE` then repaints
|
||||
// a tab whose `page` is still nil: this probe raised
|
||||
// `Blizzard_AuctionUI.lua:837: attempt to perform arithmetic on field 'page'` twice a
|
||||
// run doing exactly that, at WARN, where nothing was looking. Clicking the tab is both
|
||||
// the faithful drive ("exactly as a click would", this file's header) and what makes
|
||||
// step (6c) a real assertion instead of a permanently red one.
|
||||
if last_ask == 0.0 || now - last_ask > OWNER_REASK_SECS {
|
||||
crate::ui_script::run_or_warn(&script, "GetOwnerAuctionItems()");
|
||||
let ask = if last_ask == 0.0 {
|
||||
// The tab's own OnShow issues the query, once per window session
|
||||
// (`AuctionFrame.gotAuctions`), and sets the page the repaint needs.
|
||||
"AuctionFrameTab_OnClick(3)"
|
||||
} else {
|
||||
"GetOwnerAuctionItems()"
|
||||
};
|
||||
crate::ui_script::run_or_warn(&script, ask);
|
||||
probe.phase = Phase::OwnerList {
|
||||
since,
|
||||
last_ask: now,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! window is first-party, no Lua click surface to drive); the live Lua VM is only touched for the
|
||||
//! bonus refusal step's `UI_ERROR_MESSAGE` observation, the mail probe's own idiom.
|
||||
//!
|
||||
//! ## The banker (live-DB verified this session, `/Users/sam/dev/vmangos-deploy` → `characters`/
|
||||
//! ## The banker (live-DB verified against the local vmangos, `characters`/
|
||||
//! `mangos` DBs)
|
||||
//!
|
||||
//! Soleil Stonemantle, creature entry 5099, spawn guid 12629, map 0 (Ironforge, The Vault), pos
|
||||
@@ -23,7 +23,7 @@
|
||||
//!
|
||||
//! vmangos `Chat.cpp`'s `modifyCommandTable` pins `.modify money` at `SEC_BASIC_ADMIN` (4,
|
||||
//! `Common.h` `AccountTypes`) — one level ABOVE `SEC_GAMEMASTER` (3), the level every probe account
|
||||
//! is provisioned at (method.md). So `.modify money` is refused server-side for a probe login,
|
||||
//! is provisioned at (docs/METHOD.md). So `.modify money` is refused server-side for a probe login,
|
||||
//! exactly the same floor [`super::probe_taxi`]'s module doc already found for the taxi fare. This
|
||||
//! probe never sends it: step (e) reads the character's live `money` field instead (DB-verified
|
||||
//! this session: `Probeone` carries 100000 copper, `bank_bag_slots` 0 — comfortably funds the
|
||||
@@ -36,7 +36,8 @@
|
||||
//! WOW_DATA=WoW/Data WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
|
||||
//! WOW_PROBE_BANK=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — this worktree is `pool-1` → `probe1`/`pprobe1`/`Probeone`).
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill).
|
||||
//! Non-combat; GM mode is left exactly as found. An outer `timeout` + grep on `PROBE_BANK:` is the
|
||||
//! whole harness; the probe self-exits ([`super::probes::ProbeExitPlugin`]'s pattern) once DONE.
|
||||
|
||||
|
||||
@@ -93,8 +93,7 @@
|
||||
//! assignment reads off it (0649, 0679).
|
||||
//!
|
||||
//! Non-combat — the probe never attacks and never stands anywhere contested; with one player in
|
||||
//! the instance there is nobody to fight. Pair with the SLOT-KEYED probe identity
|
||||
//! (`method.md`, "The local vmangos server").
|
||||
//! the instance there is nobody to fight. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill).
|
||||
|
||||
use bevy::ecs::system::NonSendMut;
|
||||
use bevy::prelude::*;
|
||||
@@ -263,7 +262,7 @@ const GHOST_SAMPLES: u32 = 11;
|
||||
const FLAG_SAMPLES: u32 = 5;
|
||||
|
||||
/// `SPIRITGUIDE` — `UNIT_NPC_FLAGS` bit 6, the flag the reference's area-spirit-healer acquire
|
||||
/// scan keys on (wow-re `interact-dead-fork-and-npc-service-ladder.md` §C row 6).
|
||||
/// scan `0x4924c0` keys on.
|
||||
const NPC_FLAG_SPIRITGUIDE: u32 = 1 << 6;
|
||||
|
||||
/// The **event tap** — a Lua frame the probe installs on entry that records every battleground
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
//! answers `none`. A body left queued is not harmful, only surprising: every later login on that
|
||||
//! account carries an extra `SMSG_BATTLEFIELD_STATUS` in its burst.
|
||||
//!
|
||||
//! Non-combat. Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
|
||||
//! WOW_CHAR=Probe<N-spelled>`; `method.md`, "The local vmangos server"), and `WOW_NOSOUND=1` when
|
||||
//! Non-combat. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill), and `WOW_NOSOUND=1` when
|
||||
//! it runs unattended. One `timeout`'d run plus a grep for `PROBE bgqueue:` is the whole harness.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! visible against a real server sending real bytes, which is what this probe puts in front of
|
||||
//! them.
|
||||
//!
|
||||
//! ## The innkeeper (live-DB verified this session, `/Users/sam/dev/vmangos-deploy` → `mangos` DB)
|
||||
//! ## The innkeeper (live-DB verified against the local vmangos, `mangos` DB)
|
||||
//!
|
||||
//! Innkeeper Keldamyr — `creature_template.entry = 6736`, spawn `creature.guid = 46343`, **map 1**
|
||||
//! (Teldrassil, Dolanaar), position `(9802.21, 982.608, 1313.98)`,
|
||||
@@ -77,8 +77,8 @@
|
||||
//! WOW_DATA=WoW/Data WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
|
||||
//! WOW_PROBE_BINDER=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — this worktree is `pool-1` → `probe1`/`pprobe1`/`Probeone`;
|
||||
//! method.md "The local vmangos server"). Non-combat, and GM mode is left exactly as found. An
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). Non-combat, and GM mode is left exactly as found. An
|
||||
//! outer `timeout` + grep on `PROBE_BINDER:` is the whole harness; the probe self-exits once DONE.
|
||||
//!
|
||||
//! Every step SKIPs with a note rather than FAILing for an environmental problem (the NPC never
|
||||
@@ -224,7 +224,7 @@ fn last_event(script: &UiScript) -> String {
|
||||
|
||||
/// The `CHAT_MSG_SYSTEM` lines seen since the hook went in, newest last — step 6's second
|
||||
/// observation. `SMSG_PLAYERBOUND` prints `ERR_DEATHBIND_SUCCESS_S` here (`0x5e3d3f` →
|
||||
/// `DisplayError(0x138)`, chat type 238 = CHAT_MSG_SYSTEM; wow-re, decision 1335).
|
||||
/// `DisplayError(0x138)`, chat type 238 = CHAT_MSG_SYSTEM; decision 1335).
|
||||
fn system_lines(script: &UiScript) -> Vec<String> {
|
||||
script
|
||||
.eval::<Vec<String>>("return ProbeBinderSystemLines or {}")
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! The world-book live probe (`WOW_PROBE_BOOK=1`) — B240's instrument: what does having the item-
|
||||
//! text reader open actually cost per frame, on the real object the report names?
|
||||
//!
|
||||
//! Goudy, 2026-08-09 (`#bugs` `1535810645956632686`): *"page text with html text absolutely
|
||||
//! annihilates performance"* — ~50% fps drop (62 → 36 fps, 16.0 → 28.0 ms) while the *Alliance
|
||||
//! Military Ranks* plaque's reader is up, recovering the moment it closes. That is a **frame-cost
|
||||
//! A/B**, and eyeballing an fps counter is exactly the way not to settle one (method.md's
|
||||
//! The symptom: a ~50% fps drop (62 → 36 fps, 16.0 → 28.0 ms) while the *Alliance Military Ranks*
|
||||
//! plaque's HTML page is up in the reader, recovering the moment it closes. That is a **frame-cost
|
||||
//! A/B**, and eyeballing an fps counter is exactly the way not to settle one (docs/METHOD.md's
|
||||
//! "timing and feel are measured, never eyeballed"). So this probe teleports to the plaque, samples
|
||||
//! the UI pass's own per-phase meter ([`crate::ui_script::UiFrameCost`]) with the reader CLOSED,
|
||||
//! opens it on the real route, samples again, and prints the two side by side — including how many
|
||||
@@ -13,7 +12,7 @@
|
||||
//!
|
||||
//! The object: `GameObject` 3011 (`gameobject_template` entry 2857, `GAMEOBJECT_TYPE_TEXT` = 9)
|
||||
//! in Stormwind's Old Town, whose `data[0]` is `page_text` 2676 — a 647-byte HTML body. The
|
||||
//! `.go xyz` below is Goudy's own reported position (his debug panel: `-8760.2 402.3 103.9`).
|
||||
//! `.go xyz` below is the reported standing position, off the debug panel: `-8760.2 402.3 103.9`.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
@@ -21,8 +20,8 @@
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
|
||||
//! WOW_PROBE_BOOK=1 cargo run -q -p benilla --release
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, method.md
|
||||
//! "The local vmangos server"). An outer `timeout` + a grep on `PROBE_BOOK:` is the whole harness;
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). An outer `timeout` + a grep on `PROBE_BOOK:` is the whole harness;
|
||||
//! the probe self-exits ([`super::probes::ProbeExitPlugin`]'s pattern) once DONE.
|
||||
|
||||
use bevy::prelude::*;
|
||||
@@ -36,8 +35,8 @@ use crate::player::Player;
|
||||
use crate::ui_item_text::ItemTextOpen;
|
||||
use crate::ui_script::{UiCostWanted, UiFrameCost};
|
||||
|
||||
/// Goudy's own reported standing position beside the plaque (his debug panel, screenshot
|
||||
/// `1535810480424222760-1`).
|
||||
/// The reporter's own standing position beside the plaque (their debug panel, in the report's
|
||||
/// screenshot).
|
||||
const PLAQUE_AT: [f32; 3] = [-8760.2, 402.3, 103.9];
|
||||
/// `GAMEOBJECT_TYPE_TEXT` — the strategy type a book/plaque carries (decision 1105).
|
||||
const GO_TYPE_TEXT: i32 = 9;
|
||||
@@ -131,7 +130,7 @@ fn reader_state(script: &UiScript) -> (bool, i64) {
|
||||
|
||||
/// Every string the UI currently draws — the render list is the honest place to ask what the page
|
||||
/// looks like, and the only place since `ItemTextPageText` became a `SimpleHTML` (5875's has no
|
||||
/// `GetText`; wow-re `simplehtml-markup-engine.md` §5.1).
|
||||
/// `GetText` in its method table `0x87ba80`).
|
||||
fn drawn_strings(script: &UiScript) -> Vec<String> {
|
||||
use benilla_ui::script::QuadContent;
|
||||
script
|
||||
@@ -146,7 +145,7 @@ fn drawn_strings(script: &UiScript) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// **B240's render half, checked where it was reported.** The page body is HTML; if the parse ever
|
||||
/// falls back, the reader draws the markup itself — which is what Goudy photographed. So: no drawn
|
||||
/// falls back, the reader draws the markup itself — the reported symptom. So: no drawn
|
||||
/// string may contain a tag, and the page's own lines must each be there as their own block.
|
||||
fn report_render(script: &UiScript) -> u32 {
|
||||
let drawn = drawn_strings(script);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! started-`Casting` half of the cancel's inflight union), then 2 s into the bar inject a real
|
||||
//! `W` press into `ButtonInput<KeyCode>` — the ACTUAL controller path, so the probe drives the
|
||||
//! same move-start edge a player's key does (controller → `LocalMoveStart` →
|
||||
//! `ui_cast::local_self_cancel` → `CMSG_CANCEL_CAST`).
|
||||
//! `spell::local_self_cancel` → `CMSG_CANCEL_CAST`).
|
||||
//!
|
||||
//! The verdict is read off the run log with `WOW_CAST_TRACE=1`: the `LOCAL self-cancel` line
|
||||
//! must land beside the `SEND move StartForward` line — frames, not a server round trip, after
|
||||
@@ -12,10 +12,9 @@
|
||||
//! anything (the reap already emptied the `Casting` key it tests). The probe also logs every
|
||||
//! cast-bar phase transition ([`bar_timeline`]) so the red bar's hold/burst/fade durations are
|
||||
//! measured off the timestamps, not eyeballed. Non-combat, safe unattended
|
||||
//! (method.md's rule bans unattended *combat* probes); a hearth that completes because the
|
||||
//! (docs/METHOD.md's rule bans unattended *combat* probes); a hearth that completes because the
|
||||
//! cancel FAILED just ports the probe character home — visible in the log as the missing cancel line. Pair with
|
||||
//! the slot-keyed probe identity (`WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<N-spelled>`,
|
||||
//! method.md) + `WOW_CAST_TRACE=1 WOW_PROBE_EXIT_AT=<s>`.
|
||||
//! the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill) + `WOW_CAST_TRACE=1 WOW_PROBE_EXIT_AT=<s>`.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! the `SMSG_CHAR_CREATE` result byte, and — unless `WOW_PROBE_CHARCREATE_KEEP=1` — deletes the
|
||||
//! character it just made ([`CharRequest::Delete`]) so the account isn't littered, logging that
|
||||
//! result too. Run it **without** `WOW_CHAR` (which would auto-enter the world and leave select),
|
||||
//! against the slot-keyed probe account (`WOW_USER=probeN WOW_PASS=pprobeN`, N = your pool slot — method.md "The local vmangos server"); creating/deleting characters is a
|
||||
//! against the checkout's own probe account (`.probe-identity`, or `WOW_USER`/`WOW_PASS` — the
|
||||
//! `probe` skill); creating/deleting characters is a
|
||||
//! non-combat operation, safe to run headlessly.
|
||||
|
||||
use benilla_protocol::{messages, CharAction, CharCreateReq};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! fork arm whose whole evidence is "no other arm accepts a charter". Those are only visible
|
||||
//! against real bytes.
|
||||
//!
|
||||
//! ## The registrar (live-DB verified this session, `/Users/sam/dev/vmangos-deploy` → `mangos` DB)
|
||||
//! ## The registrar (live-DB verified against the local vmangos, `mangos` DB)
|
||||
//!
|
||||
//! Aldwin Laughlin, the Stormwind guild registrar — `creature_template.entry = 4974`, spawn
|
||||
//! `creature.guid = 79681`, **map 0**, position `(-8885.25, 614.395, 95.2576)`,
|
||||
@@ -85,10 +85,9 @@
|
||||
//! account to sign, and #5 (`ERR_GUILD_FOUNDER_S` on a successful turn-in) needs nine of them —
|
||||
//! neither is reachable from one client, so the whole sign/offer/turn-in half of the family
|
||||
//! (`SignPetition`, `OfferPetition`, `TurnInGuildCharter`, `MSG_PETITION_DECLINE`) still has no
|
||||
//! live coverage after this probe. #3 (the two closes send nothing) is *superseded*: the wow-re
|
||||
//! carve that landed alongside this file finds that closing a charter you do NOT own can send
|
||||
//! `MSG_PETITION_DECLINE`, so this probe deliberately asserts nothing about either close rather
|
||||
//! than pinning a claim that is being rewritten.
|
||||
//! live coverage after this probe. #3 (the two closes send nothing) is *superseded*: closing a
|
||||
//! charter you do NOT own can send `MSG_PETITION_DECLINE` (`0x4f3f60`), so this probe deliberately
|
||||
//! asserts nothing about either close rather than pinning a claim that is being rewritten.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
@@ -96,8 +95,7 @@
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
|
||||
//! WOW_PROBE_CHARTER=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — this worktree is `pool-0` → `probe0`/`pprobe0`/`Probezero`;
|
||||
//! method.md "The local vmangos server". **Never the default `one` account** — a login on it kicks
|
||||
//! (the checkout's probe identity, the `probe` skill. **Never a player's account** — a login on it kicks
|
||||
//! the director's live session.) `WOW_NOSOUND=1` because an unattended probe must not play zone
|
||||
//! music into the director's room; `caffeinate -dis` is **not** needed for a run this short — the
|
||||
//! whole sequence is a handful of round trips and finishes in well under a minute.
|
||||
@@ -117,7 +115,9 @@ use benilla_ui::script::UiScript;
|
||||
|
||||
use super::probes::ProbeClock;
|
||||
use crate::items::Items;
|
||||
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
|
||||
use crate::net::{
|
||||
ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, Objects, SelfPlayer,
|
||||
};
|
||||
use crate::player::Player;
|
||||
use crate::target::cursor_mode::npc_flags;
|
||||
use crate::ui_gossip::GossipState;
|
||||
@@ -160,7 +160,7 @@ const REQUIRED_SIGNATURES: i64 = 9;
|
||||
const FRESH_SIGNATURES: i64 = 0;
|
||||
/// Copper handed to the probe body up front so the buy can never fail for funds. `.modify money`
|
||||
/// is `SEC_BASIC_ADMIN` (4) in vmangos's `Chat.cpp` command table and every `probeN` account is
|
||||
/// gmlevel **6** (method.md, decision 0651), so it lands; with no selection it targets the sender
|
||||
/// gmlevel **6** (docs/METHOD.md, decision 0651), so it lands; with no selection it targets the sender
|
||||
/// (`ChatHandler::GetSelectedPlayer`, `Chat.cpp:2601-2612`), which is why it is sent before the
|
||||
/// probe touches an NPC.
|
||||
const FUND_COPPER: u32 = 100_000;
|
||||
@@ -458,6 +458,7 @@ fn charter_probe(
|
||||
mut probe: ResMut<CharterProbe>,
|
||||
gossip: Res<GossipState>,
|
||||
registrar: Res<GuildRegistrarState>,
|
||||
objects: Objects,
|
||||
items: Res<Items>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
@@ -797,7 +798,12 @@ fn charter_probe(
|
||||
};
|
||||
return;
|
||||
}
|
||||
let found = find_item(&store.0, &items, CHARTER_ITEM_ENTRY, ItemSearch::default());
|
||||
let found = find_item(
|
||||
&store.0,
|
||||
&objects,
|
||||
CHARTER_ITEM_ENTRY,
|
||||
ItemSearch::default(),
|
||||
);
|
||||
// The template has to have landed too, and it is folded into the SAME `Option` as the
|
||||
// item rather than short-circuiting on its own: the click dispatcher's charter arm is
|
||||
// a **template flag** test (`ITEM_FLAG_CHARTER`), so a click made before the answer
|
||||
@@ -1168,9 +1174,12 @@ fn charter_probe(
|
||||
// destroys whatever whole stack sits at the addressed position, so a stale pair
|
||||
// would destroy the wrong item; nothing is expected to move a charter, and that is
|
||||
// exactly the kind of expectation a destructive send must not rest on.
|
||||
let Some((bag_index, slot, _)) =
|
||||
find_item(&store.0, &items, CHARTER_ITEM_ENTRY, ItemSearch::default())
|
||||
else {
|
||||
let Some((bag_index, slot, _)) = find_item(
|
||||
&store.0,
|
||||
&objects,
|
||||
CHARTER_ITEM_ENTRY,
|
||||
ItemSearch::default(),
|
||||
) else {
|
||||
probe.pass(
|
||||
9,
|
||||
"cleanup",
|
||||
@@ -1197,8 +1206,12 @@ fn charter_probe(
|
||||
};
|
||||
return;
|
||||
}
|
||||
let still_there =
|
||||
find_item(&store.0, &items, CHARTER_ITEM_ENTRY, ItemSearch::default());
|
||||
let still_there = find_item(
|
||||
&store.0,
|
||||
&objects,
|
||||
CHARTER_ITEM_ENTRY,
|
||||
ItemSearch::default(),
|
||||
);
|
||||
if still_there.is_none() {
|
||||
probe.pass(
|
||||
9,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! The chest live probe (`WOW_PROBE_CHEST=1`) — **B84's instrument**: does the player kneel at an
|
||||
//! open chest?
|
||||
//!
|
||||
//! Goudy, 2026-07-26 (`#bugs` `1530708187173359646`): *"No looting/opening animation when using a
|
||||
//! chest"*, with a benilla/reference pair at the same Mirror Lake Orchard chest — the reference
|
||||
//! The symptom: no looting animation when opening a chest. At the same Mirror Lake Orchard chest,
|
||||
//! benilla and the reference side by side show the reference
|
||||
//! gnome kneeling over the open loot window, ours standing bolt upright. Decision 0515 had shipped
|
||||
//! the kneel keyed to the `CMSG_LOOT` send alone and recorded a chest's silence as *correct*; 1471
|
||||
//! is the correction (the real client also arms the latch in `OnLootResponse 0x5eb900`, and a chest
|
||||
//! never sends `CMSG_LOOT` at all).
|
||||
//!
|
||||
//! **A kneel is a number here, not a picture** (`method.md` step 4: timing and pose questions are
|
||||
//! **A kneel is a number here, not a picture** (`docs/METHOD.md` step 4: timing and pose questions are
|
||||
//! settled with instruments). The probe reads the self unit's own
|
||||
//! [`AnimDriver::active_anim`][crate::creature_anim::AnimDriver::active_anim] — the id the base
|
||||
//! selector actually landed on — so the verdict is `50` (`Loot`) or it is not, with no screenshot
|
||||
@@ -38,8 +38,8 @@
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
|
||||
//! WOW_PROBE_CHEST=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
|
||||
//! "The local vmangos server"). `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]` aims it at a different
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]` aims it at a different
|
||||
//! object; the default is a live `Battered Chest` spawn (`gameobject.guid` 26756, template 2843) in
|
||||
//! the Mirror Lake stretch of Elwynn, the same corner of the world the report came from. A chest is
|
||||
//! a respawning spawn point: if someone emptied that one it will be missing for its respawn timer,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! The director, 2026-08-22: *"Clams don't open anymore. I right click them and they turn gray
|
||||
//! correctly but no loot window opens ever."* Both halves of that sentence are readings this probe
|
||||
//! takes separately, because they come from two different pre-send writes in the same emitter
|
||||
//! (`0x5edc80`, wow-re `loot-anim-leg.md` §5 / `inventory-change-failure-display.md` §8):
|
||||
//! (`0x5edc80`):
|
||||
//!
|
||||
//! - the **grey lock** ([`PendingItemOps`], `0x4953e0` at `0x5edcd9`) — the half that kept working;
|
||||
//! - the **loot latch** ([`LootLatch`], `0x5edcc0`) — the half decision 1477 left unmodelled, on
|
||||
@@ -34,8 +34,8 @@
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
|
||||
//! WOW_PROBE_CLAM=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
|
||||
//! "The local vmangos server"). `WOW_PROBE_CLAM=<entry>` aims it at a different openable template;
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_PROBE_CLAM=<entry>` aims it at a different openable template;
|
||||
//! the default is a Small Barnacled Clam (7973 — `Flags` LOOTABLE with `LockID = 0`, so it is
|
||||
//! openable the moment it exists: no key, no lockpicking). The probe `.additem`s its own copy and
|
||||
//! subtracts it again on the way out, so it leaves the character as it found it.
|
||||
@@ -52,7 +52,7 @@ use benilla_ui::script::UiScript;
|
||||
|
||||
use super::probes::ProbeClock;
|
||||
use crate::items::Items;
|
||||
use crate::net::{ChatKind, ClientCommand, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::net::{ChatKind, ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer};
|
||||
use crate::pending_item_ops::PendingItemOps;
|
||||
use crate::ui_loot::{LootConfig, LootLatch, LootState};
|
||||
|
||||
@@ -184,6 +184,7 @@ fn window_open(script: &UiScript, loot: &LootState) -> bool {
|
||||
fn find_in_backpack(
|
||||
store: &ObjectStore,
|
||||
entry: u32,
|
||||
objects: &Objects,
|
||||
items: &Items,
|
||||
net: &NetCommands,
|
||||
) -> Option<(u64, u32)> {
|
||||
@@ -195,7 +196,7 @@ fn find_in_backpack(
|
||||
.filter(|g| *g != 0)
|
||||
.map(|g| (i, g))
|
||||
})
|
||||
.find(|(_, guid)| items.object(*guid).and_then(|f| f.object_entry()) == Some(entry))?;
|
||||
.find(|(_, guid)| objects.object(*guid).and_then(|f| f.object_entry()) == Some(entry))?;
|
||||
// `template` asks the server once on a miss and answers `None` until the reply lands, which is
|
||||
// exactly the "not stocked yet" state this returns.
|
||||
items
|
||||
@@ -209,6 +210,7 @@ fn clam_probe(
|
||||
mut probe: ResMut<ClamProbe>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
objects: Objects,
|
||||
items: Res<Items>,
|
||||
mut cfg: ResMut<LootConfig>,
|
||||
loot: Res<LootState>,
|
||||
@@ -240,7 +242,7 @@ fn clam_probe(
|
||||
probe.phase = Phase::Stocking { sent_at: now };
|
||||
}
|
||||
Phase::Stocking { sent_at } => {
|
||||
if let Some((guid, slot)) = find_in_backpack(store, entry, &items, &net) {
|
||||
if let Some((guid, slot)) = find_in_backpack(store, entry, &objects, &items, &net) {
|
||||
info!(
|
||||
"PROBE_CLAM: item {entry} is guid {guid:#x} in backpack slot {slot} — \
|
||||
sampling {CONTROL_FRAMES} control frames with it UNCLICKED"
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
//! report the seam: aboard → map flip (TRANSFER_PENDING / NEW_WORLD riding branch, logged by
|
||||
//! the net layer) → still riding → arrived docked on the far continent. Every phase edge prints
|
||||
//! a `PROBE crossing:` line, so an outer `timeout`d run + grep is the whole harness. Non-combat.
|
||||
//! Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
|
||||
//! WOW_CHAR=Probe<N-spelled>` for a `pool-N` worktree — method.md "The local vmangos server";
|
||||
//! a shared account gets kicked by parallel sessions mid-ride).
|
||||
//! Pair with the checkout's own probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR
|
||||
//! — the `probe` skill; a shared account gets kicked by parallel sessions mid-ride).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
|
||||
name: "WOW_PROBE_EXIT_AT",
|
||||
purpose: "<secs> — exit the app after N wall seconds; bounds any scripted live probe's lifetime",
|
||||
// Fires on `ProbeClock` at `<secs>`, and it is the one probe variable a trace-only run
|
||||
// sets on its own (`WOW_MOVE_TRACE`/`WOW_STREAM_TRACE` legs, method.md) — 0794's
|
||||
// sets on its own (`WOW_MOVE_TRACE`/`WOW_STREAM_TRACE` legs, docs/METHOD.md) — 0794's
|
||||
// throttled camera leg was exactly such a run.
|
||||
wall_clock: true,
|
||||
},
|
||||
|
||||
@@ -93,8 +93,8 @@
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
|
||||
//! WOW_PROBE_GMTICKET=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — this worktree is `pool-3` → `probe3`/`pprobe3`/`Probethree`;
|
||||
//! method.md "The local vmangos server"). Non-combat; the probe never drives the body and sends no
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). Non-combat; the probe never drives the body and sends no
|
||||
//! GM command, so GM mode and position are left exactly as found — but note that the body is not
|
||||
//! necessarily *still* when a run starts (see [`REST_EPS`]). An outer `timeout` + grep on
|
||||
//! `PROBE_GMTICKET:` is the whole harness; the probe self-exits once DONE. Nothing here is a
|
||||
@@ -163,11 +163,11 @@ const REST_TIMEOUT_SECS: f64 = 20.0;
|
||||
/// the packet, before the db-expect line stops being trustworthy (yd, WoW space).
|
||||
const STAMP_DRIFT_EPS: f32 = 0.05;
|
||||
|
||||
/// The operator's half of step 4, printed verbatim so it can be pasted.
|
||||
const DB_QUERY: &str = "cd /Users/sam/dev/vmangos-deploy && docker compose exec -T database \
|
||||
mariadb -umangos -pmangos characters -e \"SELECT ticket_id, name, \
|
||||
ticket_type, map, position_x, position_y, position_z, closed_by, message \
|
||||
FROM gm_tickets ORDER BY ticket_id DESC LIMIT 1;\"";
|
||||
/// The operator's half of step 4: the row to read back off the server's `characters` database,
|
||||
/// printed verbatim so it can be pasted into whatever reaches that database.
|
||||
const DB_QUERY: &str = "SELECT ticket_id, name, ticket_type, map, position_x, position_y, \
|
||||
position_z, closed_by, message FROM gm_tickets ORDER BY ticket_id DESC \
|
||||
LIMIT 1;";
|
||||
|
||||
pub(crate) struct ProbeGmTicketPlugin;
|
||||
|
||||
@@ -563,7 +563,8 @@ fn gm_ticket_probe(
|
||||
warn!(
|
||||
"PROBE_GMTICKET: SKIP (4 db) — operator step, by design: the client never \
|
||||
reads map/position back, so only the server's own row proves they landed. \
|
||||
After the run: {DB_QUERY} — and compare against the db-expect line(s)."
|
||||
After the run, against the server's `characters` database: {DB_QUERY} — \
|
||||
and compare against the db-expect line(s)."
|
||||
);
|
||||
let text = unique_text(" edited");
|
||||
if !run_or_skip(
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
|
||||
//! WOW_PROBE_GOQUEST=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
|
||||
//! "The local vmangos server"). `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]` aims it elsewhere; the
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]` aims it elsewhere; the
|
||||
//! default is the **Goldshire wanted poster** — `gameobject.guid` 26843, template 68
|
||||
//! `Wanted Poster`, `type` 2 `GAMEOBJECT_TYPE_QUESTGIVER`, `flags` 4 `INTERACT_COND`, offering
|
||||
//! quest 176 `Wanted: "Hogger"` (`MinLevel` 5, `QuestLevel` 11). It is a single **unpooled** spawn
|
||||
@@ -90,7 +90,7 @@ const NPC_FLAG_QUESTGIVER: u32 = 0x2;
|
||||
/// `CHAT`, which would need `level > QuestLevel + Quests.LowLevelHideDiff`).
|
||||
const LOW_LEVEL: u32 = 2;
|
||||
const HIGH_LEVEL: u32 = 8;
|
||||
/// `DialogStatus` ids the verdict is written in (wow-re `questgiver-marker.md` §Q1's own map).
|
||||
/// `DialogStatus` ids the verdict is written in (the keys of the status map `0x80c454`).
|
||||
const STATUS_UNAVAILABLE: u32 = 1;
|
||||
const STATUS_AVAILABLE: u32 = 5;
|
||||
|
||||
@@ -319,9 +319,9 @@ fn goquest_probe(
|
||||
Phase::Level { to, since } => {
|
||||
if level == to {
|
||||
// Force the sweep this window depends on. A GameObject GUID reaches the wire ONLY
|
||||
// from a sweep (wow-re §W14.8), and the level change above is one — but it is a
|
||||
// no-op when the character already stood at this level, so the epoch bump makes
|
||||
// the window's sweep unconditional.
|
||||
// from a sweep (`0x5eb159`, `0x5eb456`), and the level change above is one — but it
|
||||
// is a no-op when the character already stood at this level, so the epoch bump
|
||||
// makes the window's sweep unconditional.
|
||||
quest.bump_reask();
|
||||
info!("PROBE_GOQUEST: at level {to}, swept — reading the window");
|
||||
probe.phase = Phase::Read {
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
//! — distance, view radius, and which of the two draws it takes — so the run says *what would be
|
||||
//! on screen*, not merely that a packet arrived.
|
||||
//!
|
||||
//! Non-combat. Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
|
||||
//! WOW_CHAR=Probe<N-spelled>`; `method.md`, "The local vmangos server"), and `WOW_NOSOUND=1` when
|
||||
//! Non-combat. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill), and `WOW_NOSOUND=1` when
|
||||
//! it runs unattended. One `timeout`'d run plus a grep for `PROBE guardpoi:` is the whole harness.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! `PROBE_MAIL: DONE pass=<n> fail=<m>` summary. Modeled closely on [`super::probe_taxi`] (same
|
||||
//! phase-machine shape, same trace style, same self-terminating exit).
|
||||
//!
|
||||
//! ## The GM `.send` syntax (verified `/Users/sam/wre/vmangos-src`, `Chat/Chat.cpp` +
|
||||
//! ## The GM `.send` syntax (verified against the vmangos source, `Chat/Chat.cpp` +
|
||||
//! `Commands/MiscCommands.cpp`)
|
||||
//!
|
||||
//! `sendCommandTable[]` (Chat.cpp ~l.931): `.send mail <name> "subject" "text"` is
|
||||
@@ -39,9 +39,8 @@
|
||||
//! WOW_DATA=<vanilla Data dir> WOW_USER=probe2 WOW_PASS=pprobe2 WOW_CHAR=Probetwo \
|
||||
//! WOW_PROBE_MAIL=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — `pool-N` → `WOW_USER=probeN WOW_PASS=pprobeN
|
||||
//! WOW_CHAR=Probe<N-spelled>`, method.md "The local vmangos server"; this worktree is `pool-2` →
|
||||
//! `probe2`/`pprobe2`/`Probetwo`). `WOW_CHAR` doubles as the probe's own mail-send target — read
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_CHAR` doubles as the probe's own mail-send target — read
|
||||
//! once at world-enter, never hardcoded. An outer `timeout` + grep on `PROBE_MAIL:` is the whole
|
||||
//! harness; the probe self-exits (the [`super::probes::ProbeExitPlugin`] pattern) once DONE.
|
||||
|
||||
@@ -359,7 +358,7 @@ fn mail_probe(
|
||||
let Some(idx) = index_of(&mail, letter_id) else {
|
||||
return; // shouldn't happen the same frame we just found it — re-poll
|
||||
};
|
||||
// CheckInbox() called twice, idempotently (wow-re §5's 60s client-side throttle,
|
||||
// CheckInbox() called twice, idempotently (`0x4aeab0`'s 60s client-side throttle,
|
||||
// decision 0548 §2/0544) — proves a rapid re-call is a no-op, not a packet storm.
|
||||
if let Err(e) = script.run(&format!("CheckInbox() CheckInbox() GetInboxText({idx})")) {
|
||||
error!("PROBE_MAIL: FAIL (b) — GetInboxText({idx}) errored: {e}");
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//! once in-world, periodically fire the attack-nearest core ([`AttackNearestRequest`] — the
|
||||
//! action-bar attack's own no-selection path), so the probe character fights whatever is closest
|
||||
//! while the `benilla_assets::trace` sink (`WOW_MOVE_TRACE=<path>`) records the swing→impact→spawn combat-text
|
||||
//! timeline for pair-birth analysis. Pair with the slot-keyed probe identity (method.md) + an outer
|
||||
//! timeline for pair-birth analysis. Pair with the checkout's probe identity (the `probe` skill) + an outer
|
||||
//! `timeout`.
|
||||
//!
|
||||
//! **Do NOT run this unattended** (director's rule, 2026-07-14, `method.md` "The local vmangos
|
||||
//! **Do NOT run this unattended** (director's rule, 2026-07-14, `docs/METHOD.md` "The local vmangos
|
||||
//! server"): the probe fights back-to-back with no health awareness and the character DIES (Tri
|
||||
//! did). Combat runs are director-assisted — ask, or hand an eyeball script.
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
//!
|
||||
//! ## What it asks, and why it is a probe rather than a test
|
||||
//!
|
||||
//! The unit tests in `crate::ui_models` pin the leg's arithmetic — the client's diagonal-FOV
|
||||
//! matrix against wow-re's worked numbers, and the three cancellations. What they cannot reach is
|
||||
//! The unit tests in `crate::ui_models` pin the leg's arithmetic — the client's diagonal-FOV matrix
|
||||
//! (`0x5c3cc0`) against its worked numbers, and the three cancellations. What they cannot reach is
|
||||
//! the half that only exists at run time: does the engine resolve the camera at all, does the
|
||||
//! renderer find the record, does a camera get spawned, aimed, and pointed at a real cell of a
|
||||
//! real atlas. So this probe drives the **whole live pipeline** from Lua and reads the result off
|
||||
//! the entities the renderer actually built.
|
||||
//!
|
||||
//! The measurement is numeric, not visual (method.md's rule: a capture can confirm an existence
|
||||
//! The measurement is numeric, not visual (docs/METHOD.md's rule: a capture can confirm an existence
|
||||
//! fact, it cannot measure one). Each leg projects three model-space probe points through the
|
||||
//! camera and the root the renderer placed this frame, and compares the resulting NDC against the
|
||||
//! baseline's:
|
||||
@@ -19,11 +19,10 @@
|
||||
//! - `scale` — `SetModelScale(3)` must not move a pixel. The authored eye is carried through the
|
||||
//! model's root transform, so the camera scales with the model.
|
||||
//! - `position` — `SetPosition(0.4, −0.3, 0.9)` must not move a pixel, for the same reason.
|
||||
//! - `facing` — `SetFacing(1.0)` must not move a pixel **either**. That one is the finding this
|
||||
//! work commissioned (wow-re `modelframe-facing-cancels.md`): the up vector `0x7ac640` builds is
|
||||
//! model-space `+Z` at `roll = 0`, which is the axis the facing turns about, so eye, target,
|
||||
//! geometry and up all turn together. `modelframe-render-law.md` §2's "only `SetFacing` shows"
|
||||
//! is scoped to `<PlayerModel>`'s frozen camera.
|
||||
//! - `facing` — `SetFacing(1.0)` must not move a pixel **either**: the up vector `0x7ac640` builds
|
||||
//! is model-space `+Z` at `roll = 0`, which is the axis the facing turns about, so eye, target,
|
||||
//! geometry and up all turn together. The rule that only `SetFacing` shows is scoped to
|
||||
//! `<PlayerModel>`'s frozen camera (`0x7acf10`).
|
||||
//! - `ortho` — the control that must NOT hold: `SetCamera(9)` is past the file's camera count, so
|
||||
//! the widget installs the NULL camera and falls to the orthographic leg, where a facing is a
|
||||
//! plain roll in the screen plane. A probe whose "identical" legs all passed because nothing was
|
||||
@@ -38,7 +37,7 @@
|
||||
//! WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive WOW_UNATTENDED=1 WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_MODEL_CAMERA=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — method.md "The local vmangos server"). `WOW_TILE_TRACE=1`
|
||||
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)). `WOW_TILE_TRACE=1`
|
||||
//! alongside it prints the leg, the record, the eye and the matrix per pane per frame.
|
||||
|
||||
use bevy::camera::visibility::RenderLayers;
|
||||
@@ -56,8 +55,8 @@ use crate::ui_models::{TilePerspectiveCamera, TileRoot, UiModelTiles};
|
||||
/// index 1 type 1 at eye `(5.5556, 0, 1.8056)` fov `0.67620`), so it exercises the raw-index
|
||||
/// selection AND has an index past the count for the orthographic control.
|
||||
const FILE: &str = r"Creature\\Wolf\\Wolf.mdx";
|
||||
/// The pane's size in FrameXML units — the pet pane's own `318×224`, whose aspect is wow-re's
|
||||
/// worked example (`camera-law.md` §12.1: `θ = 0.287938 · fov`).
|
||||
/// The pane's size in FrameXML units — the pet pane's own `318×224`, whose aspect is a worked
|
||||
/// example of the projection `0x5c3cc0` (`θ = 0.287938 · fov`).
|
||||
const PANE_W: f32 = 318.0;
|
||||
const PANE_H: f32 = 224.0;
|
||||
/// Frames to let a change settle before the numbers are read: the extract republishes the request
|
||||
@@ -192,7 +191,7 @@ fn read_leg(
|
||||
|
||||
fn shoot(commands: &mut Commands, name: &str) {
|
||||
// Every file we write goes through `local_state` — never into the WoW install, never a
|
||||
// platform config dir (the contract's one-folder rule).
|
||||
// platform config dir (docs/METHOD.md's one-folder rule).
|
||||
let Some(path) = crate::local_state::home().map(|d| d.join(format!("{name}.png"))) else {
|
||||
warn!("PROBE_MODEL_CAMERA: no local-state folder — skipping the {name} shot");
|
||||
return;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! leader/loot management, pings — solo, with the probe account as the other member. It also
|
||||
//! accepts **duel** challenges (decision 0637), which is what makes the duel arc's two-client
|
||||
//! questions answerable at all: whether the opponent turns hostile, whether the arbiter flag
|
||||
//! plants. Non-combat (the unattended-combat ban, `method.md` "The local vmangos server", stands
|
||||
//! plants. Non-combat (the unattended-combat ban, `docs/METHOD.md` "The local vmangos server", stands
|
||||
//! untouched: this probe answers a challenge but never swings, and a duel nobody strikes in
|
||||
//! simply times out). Pair with the slot-keyed probe identity (`WOW_USER=probeN …`, method.md).
|
||||
//! simply times out). Pair with the checkout's probe identity (the `probe` skill).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
//! `player::controller`, gated on `mouselook` (a held right button or MOVEANDSTEER) *and* a moving
|
||||
//! OS cursor inside the viewport. An unfocused probe window has neither. So the whole observed-swim
|
||||
//! lane — the wire's pitch tail, the dead-reckon's pitched travel basis, and the body-pitch render
|
||||
//! law (decision 0464 TU-A) — was **unreachable from any script**: a benilla client could be made
|
||||
//! to swim, but only ever level. The tilt shipped in July 2026 with no test, no trace field and no
|
||||
//! way to drive it, which is why "do other swimmers tilt?" had no answer short of the director's
|
||||
//! eye, and why a two-client probe of it could not be written at all.
|
||||
//! law (`0x60a110`, decision 0464) — was **unreachable from any script**: a benilla client could be
|
||||
//! made to swim, but only ever level. The tilt shipped in July 2026 with no test, no trace field
|
||||
//! and no way to drive it, which is why "do other swimmers tilt?" had no answer short of the
|
||||
//! director's eye, and why a two-client probe of it could not be written at all.
|
||||
//!
|
||||
//! Format: `WOW_PROBE_PITCH="<deg>@<start_s>[:<deg_per_sec>][;…]"` — `deg` is nose-**up** positive,
|
||||
//! matching `Player::mover_pitch` and the wire's own sign. `"-30@22;30@40"` aims 30° down at
|
||||
//! twenty-two seconds and 30° up at forty; `"-45@20:9"` starts level-ish and sweeps at 9°/s from
|
||||
//! twenty seconds on, which is what a slow real dive looks like on the wire. The last entry whose
|
||||
//! start has passed wins, and it is re-asserted **every frame** — the reference holds an unsteered
|
||||
//! pitch rather than levelling it (TU-B(c)), so a one-shot write would be indistinguishable from a
|
||||
//! pitch rather than levelling it, so a one-shot write would be indistinguishable from a
|
||||
//! held one; re-asserting is what makes the two separable when only one of them is under test.
|
||||
//!
|
||||
//! It writes [`Player::aim_pitch`] — the field the reference's `SetPitch` (`0x7c6f70`) writes,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The probe **rig** (`WOW_RIG="<spec>"`, decision 0651) — one command that puts this slot's probe
|
||||
//! The probe **rig** (`WOW_RIG="<spec>"`, decision 0651) — one command that puts this checkout's probe
|
||||
//! account into a chosen **body** and hands the session a world that is ready to test.
|
||||
//!
|
||||
//! ## Why it exists
|
||||
@@ -17,7 +17,7 @@
|
||||
//! ```text
|
||||
//! WOW_RIG="tauren druid 60 gear:heal-preraid-bis spec:heal-preraid-bis at:ThunderBluff"
|
||||
//! WOW_RIG="gnome mage 39 gear:dps-39-twink gm:off"
|
||||
//! WOW_RIG="60 gear:dps-preraid-bis" # the slot's own Probe<N>, no new character
|
||||
//! WOW_RIG="60 gear:dps-preraid-bis" # the account's own probe body, no new character
|
||||
//! WOW_RIG="nightelf druid" # just a body of that shape, level 1
|
||||
//! ```
|
||||
//!
|
||||
@@ -26,14 +26,15 @@
|
||||
//! **Race + class name a character, so the rig owns the pick.** vmangos has no class-change command
|
||||
//! (`.character race` exists; there is no `.character class`), so a different class *is* a different
|
||||
//! character — which makes creating one the honest primitive rather than a fallback. The name is
|
||||
//! derived, never invented: `<Race3><Class3><slot-word>[f]`, e.g. Tauren Druid on `pool-1` →
|
||||
//! `Taudruone`. Deterministic means the *next* session reuses the same body instead of littering a
|
||||
//! second one, and the pattern is what makes eviction safe (below). Omit race+class and the rig
|
||||
//! configures whatever `WOW_CHAR`/the slot default already logs in as.
|
||||
//! derived, never invented: `<Race3><Class3><word>[f]`, the word being this checkout's
|
||||
//! (`run_mode::rig_suffix`), e.g. a Tauren Druid for `Probeone` → `Taudruone`. Deterministic means
|
||||
//! the *next* session reuses the same body instead of littering a second one, and the pattern is
|
||||
//! what makes eviction safe (below). Omit race+class and the rig configures whatever `WOW_CHAR`
|
||||
//! already logs in as.
|
||||
//!
|
||||
//! **The roster is a cache with an eviction policy.** `CharactersPerRealm` is 10, and there are 40
|
||||
//! valid race/class pairs, so a busy account fills. The rig does not hardcode the limit: it tries
|
||||
//! the create, and only on `CHAR_CREATE_SERVER_LIMIT` evicts **the rig-named character on this slot
|
||||
//! the create, and only on `CHAR_CREATE_SERVER_LIMIT` evicts **the rig-named character of this checkout
|
||||
//! that is cheapest to rebuild** — lowest level first, ties broken by oldest — then retries once.
|
||||
//! Nothing outside the rig's own naming pattern is ever deleted, so `Probe<N>` and anything a human
|
||||
//! made are untouchable.
|
||||
@@ -44,7 +45,7 @@
|
||||
//! It always revives a dead or ghost body, whether or not you asked — that is the 121× command, and
|
||||
//! there is no session that wants to keep testing on a corpse.
|
||||
//!
|
||||
//! ## The GM verbs behind it (all verified against `/Users/sam/wre/vmangos-src`)
|
||||
//! ## The GM verbs behind it (all verified against the vmangos source, github.com/vmangos/core)
|
||||
//!
|
||||
//! | rig token | command | needs | note |
|
||||
//! |---|---|---|---|
|
||||
@@ -71,7 +72,7 @@
|
||||
//! character (give `WOW_RIG` a race+class) starts with empty bags and is the cheaper path.
|
||||
//!
|
||||
//! Non-combat throughout: the rig creates, configures, places and stops. It never fights, so the
|
||||
//! unattended-combat ban (method.md) is untouched.
|
||||
//! unattended-combat ban (docs/METHOD.md) is untouched.
|
||||
|
||||
use benilla_protocol::{messages, CharAction, CharCreateReq};
|
||||
use bevy::prelude::*;
|
||||
@@ -225,7 +226,7 @@ impl RigSpec {
|
||||
fn describe(&self) -> String {
|
||||
let body = self
|
||||
.body
|
||||
.map_or("this slot's probe character".into(), |(r, c, g)| {
|
||||
.map_or("the account's probe character".into(), |(r, c, g)| {
|
||||
format!(
|
||||
"{} {} {}",
|
||||
if g == 1 { "female" } else { "male" },
|
||||
@@ -360,13 +361,13 @@ fn drive_rig(
|
||||
messages::CHAR_CREATE_SUCCESS => rig.phase = RigPhase::AwaitRoster,
|
||||
messages::CHAR_CREATE_SERVER_LIMIT if !rig.evicted => {
|
||||
rig.evicted = true;
|
||||
match crate::run_mode::slot_word().and_then(|slot| {
|
||||
evictable(&rig.roster, rig_char_name(&rig.spec).as_deref(), slot)
|
||||
match crate::run_mode::rig_suffix().and_then(|word| {
|
||||
evictable(&rig.roster, rig_char_name(&rig.spec).as_deref(), &word)
|
||||
}) {
|
||||
Some((guid, name)) => {
|
||||
warn!(
|
||||
"rig: the account is full — evicting the cheapest rig character to rebuild, {name} \
|
||||
(guid {guid}). Only rig-named characters on this slot are ever \
|
||||
(guid {guid}). Only this checkout's rig-named characters are ever \
|
||||
deleted; Probe<N> and hand-made characters are never touched."
|
||||
);
|
||||
let _ = pick.0.send(CharRequest::Delete(guid));
|
||||
@@ -518,7 +519,7 @@ fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
|
||||
/// `game_tele` name. The facing is load-bearing exactly when a motion probe walks from the
|
||||
/// pin: `W` follows the BODY's facing, and each probe character keeps whatever facing it last
|
||||
/// had — an unpinned facing sent probe0 and probe4 down different routes from the same point,
|
||||
/// which invalidated a cross-slot A/B before 1462's sitting caught it.
|
||||
/// which invalidated a cross-checkout A/B before 1462's sitting caught it.
|
||||
fn parse_point(at: &str) -> Option<(i32, f32, f32, f32, Option<f32>)> {
|
||||
let mut parts = at.split(',').map(str::trim);
|
||||
let map = parts.next()?.parse().ok()?;
|
||||
@@ -536,9 +537,9 @@ fn parse_point(at: &str) -> Option<(i32, f32, f32, f32, Option<f32>)> {
|
||||
|
||||
fn rig_char_name(spec: &RigSpec) -> Option<String> {
|
||||
let (race, class, gender) = spec.body?;
|
||||
let slot = crate::run_mode::slot_word()?;
|
||||
let word = crate::run_mode::rig_suffix()?;
|
||||
let name = format!(
|
||||
"{}{}{slot}{}",
|
||||
"{}{}{word}{}",
|
||||
race_code(race)?,
|
||||
class_code(class)?,
|
||||
if gender == 1 { "f" } else { "" }
|
||||
@@ -550,45 +551,45 @@ fn rig_char_name(spec: &RigSpec) -> Option<String> {
|
||||
Some(chars.next()?.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The rig-named character on this slot that costs least to lose: **lowest level first**, ties
|
||||
/// The rig-named character of this checkout that costs least to lose: **lowest level first**, ties
|
||||
/// broken by oldest (the enum is ordered by `create_time` — vmangos `HandleCharEnumOpcode` — so an
|
||||
/// earlier index *is* older). Level is the proxy for invested setup: a level-1 body is 15 seconds to
|
||||
/// rebuild, a geared 60 is a minute and a talent tree. Evicting by age alone would throw away the
|
||||
/// most valuable body on the account first, which the live fill test made obvious.
|
||||
///
|
||||
/// Anything that is not a rig name for `slot` — `Probe<N>`, a hand-made character, another slot's
|
||||
/// leftovers — is invisible here, and that is what makes automatic deletion safe.
|
||||
/// Anything that is not a rig name for `word` — the probe body itself, a hand-made character,
|
||||
/// another checkout's leftovers — is invisible here, and that is what makes automatic deletion safe.
|
||||
///
|
||||
/// `slot` is a **parameter, not a `slot_word()` call inside**: reading the ambient slot here made
|
||||
/// the eviction test pass only in the worktree it was written in (`pool-1`, whose rig names end
|
||||
/// `…one`) and fail in every other slot — and in the primary checkout, where `slot_word()` is
|
||||
/// `None` and nothing matches at all. A unit test's answer must not depend on which directory the
|
||||
/// build happened in.
|
||||
/// `word` is a **parameter, not a `rig_suffix()` call inside**: reading the ambient word here made
|
||||
/// the eviction test pass only in the checkout it was written in (whose rig names end `…one`) and
|
||||
/// fail in every other — and in a checkout with no declaration, where `rig_suffix()` is `None` and
|
||||
/// nothing matches at all. A unit test's answer must not depend on which directory the build
|
||||
/// happened in.
|
||||
fn evictable(
|
||||
roster: &[benilla_protocol::Character],
|
||||
want: Option<&str>,
|
||||
slot: &str,
|
||||
word: &str,
|
||||
) -> Option<(u64, String)> {
|
||||
roster
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
!want.is_some_and(|w| c.name.eq_ignore_ascii_case(w)) && is_rig_name(&c.name, slot)
|
||||
!want.is_some_and(|w| c.name.eq_ignore_ascii_case(w)) && is_rig_name(&c.name, word)
|
||||
})
|
||||
.min_by_key(|(age, c)| (c.level, *age))
|
||||
.map(|(_, c)| (c.guid, c.name.clone()))
|
||||
}
|
||||
|
||||
/// Whether a roster name was minted by [`rig_char_name`] for this slot: a known race code, a known
|
||||
/// class code, this slot's word, and nothing but an optional `f` after it.
|
||||
fn is_rig_name(name: &str, slot: &str) -> bool {
|
||||
/// Whether a roster name was minted by [`rig_char_name`] for this checkout: a known race code, a
|
||||
/// known class code, this checkout's word, and nothing but an optional `f` after it.
|
||||
fn is_rig_name(name: &str, word: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let Some(rest) = lower.get(3..).and_then(|r| r.get(3..)) else {
|
||||
return false;
|
||||
};
|
||||
let has_codes = RACE_CODES.iter().any(|(_, c)| lower.starts_with(c))
|
||||
&& CLASS_CODES.iter().any(|(_, c)| lower[3..].starts_with(c));
|
||||
has_codes && (rest == slot || rest == format!("{slot}f"))
|
||||
has_codes && (rest == word || rest == format!("{word}f"))
|
||||
}
|
||||
|
||||
/// Race id → the 3-letter name code. `Nel`/`Und` rather than the DBC's own prefixes: unambiguous,
|
||||
@@ -770,7 +771,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_derived_name_is_deterministic_and_fits_the_server_limit() {
|
||||
// `<Race3><Class3><slot-word>[f]`, normalized the way vmangos normalizes a player name.
|
||||
// `<Race3><Class3><word>[f]`, normalized the way vmangos normalizes a player name.
|
||||
let name = |spec: &str, slot: &str| {
|
||||
let s = RigSpec::parse(spec).unwrap();
|
||||
let (race, class, gender) = s.body.unwrap();
|
||||
@@ -835,9 +836,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn eviction_spends_the_body_that_is_cheapest_to_rebuild() {
|
||||
// The slot is passed in, never read from the ambient build directory — these names are
|
||||
// pool-1's, and taking the slot from `slot_word()` made this test pass in pool-1 and fail
|
||||
// in every other worktree (and in the primary checkout, where it is `None`).
|
||||
// The word is passed in, never read from the ambient checkout — these names are one
|
||||
// checkout's, and taking the word from `rig_suffix()` made this test pass there and fail
|
||||
// in every other checkout (and in one with no declaration, where it is `None`).
|
||||
let slot = "one";
|
||||
// Roster order IS create order (vmangos enumerates by `create_time`).
|
||||
let roster = [
|
||||
@@ -859,8 +860,8 @@ mod tests {
|
||||
);
|
||||
// Nothing rig-named on this slot ⇒ nothing to evict; the caller errors rather than guessing.
|
||||
assert_eq!(evictable(&roster[..2], None, slot), None);
|
||||
// And another slot's leftovers are invisible: the same roster, read as pool-3, evicts
|
||||
// nothing. This is the assertion that would have caught the ambient-slot read.
|
||||
// And another checkout's leftovers are invisible: the same roster, read with another word,
|
||||
// evicts nothing. This is the assertion that would have caught the ambient read.
|
||||
assert_eq!(evictable(&roster, None, "three"), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
//! window instead of a gossip menu?".
|
||||
//!
|
||||
//! 1861 replaced a cursor-kind dispatch with the reference's own first-match-wins walk over
|
||||
//! `UNIT_NPC_FLAGS` ([`crate::target::click::service_arm`]; wow-re
|
||||
//! `object-layer/scratch/interact-dead-fork-and-npc-service-ladder.md` §C). **Bit 0 (GOSSIP) is
|
||||
//! `UNIT_NPC_FLAGS` ([`crate::target::click::service_arm`]; `0x5f0130`). **Bit 0 (GOSSIP) is
|
||||
//! tested first**, so the flag — not the profession — decides: a trainer or questgiver that also
|
||||
//! carries GOSSIP still opens a gossip menu, and only a flagless one opens its own window. That
|
||||
//! precedence is the whole question, and it is invisible to a unit test: the bits come off the
|
||||
@@ -21,7 +20,7 @@
|
||||
//! | *any pure questgiver with an offer* | `0x02`, GOSSIP clear | Questgiver | quest frame |
|
||||
//! | Alma Jainrose (812) | `0x10` TRAINER only | Trainer | trainer frame |
|
||||
//!
|
||||
//! The flag column is live-DB verified this session (`vmangos-deploy` → `mangos`,
|
||||
//! The flag column is live-DB verified against the local vmangos (`mangos`,
|
||||
//! `SELECT entry, name, npc_flags FROM creature_template`), and reported again from the wire on
|
||||
//! every run — a template edit shows up as a note beside the reading rather than as a mystery FAIL.
|
||||
//!
|
||||
@@ -48,7 +47,7 @@
|
||||
//! WOW_DATA=WoW/Data WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
|
||||
//! WOW_UNATTENDED=1 WOW_PROBE_SERVICE=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — method.md "The local vmangos server"). Non-combat, GM mode
|
||||
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)). Non-combat, GM mode
|
||||
//! left as found, nothing bought and nothing turned in: every leg opens a window and closes it
|
||||
//! client-side, which is what the reference's own close does (`ui_gossip`: there is no
|
||||
//! `CMSG_GOSSIP_CLOSE` in 1.12).
|
||||
@@ -78,7 +77,7 @@ use crate::ui_trainer::TrainerOpen;
|
||||
/// This constant is the reason the questgiver leg first read as a client defect: its hop point was
|
||||
/// an invented Northshire "hub" ~29 yd from Deputy Willem, so the probe called a ladder the click
|
||||
/// would never have reached, the server dropped the packet on its own distance check, and the
|
||||
/// timeout printed a FAIL about the client (method.md §6 — prove the run before reading the
|
||||
/// timeout printed a FAIL about the client (docs/METHOD.md §6 — prove the run before reading the
|
||||
/// result). Every hop point is now an NPC's own spawn, and the scan re-checks the reach.
|
||||
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
|
||||
/// Let the hop land and the tile stream before the first scan.
|
||||
@@ -215,7 +214,7 @@ struct ServiceProbe {
|
||||
/// NPC look identical in a SKIP line otherwise — and on a loaded machine the first is far more
|
||||
/// likely: at load 61 this probe logged `frame hitch: ~1010 ms` on repeat (the ~1 fps regime
|
||||
/// decisions 0713/0777/1355 name) and its 20-second scan window bought about twenty samples.
|
||||
/// `leg.sh` answers the same problem with a load guard (1157); a probe that does one thing and
|
||||
/// A leg runner answers the same problem with a load guard (1157); a probe that does one thing and
|
||||
/// exits is better served by reporting what it actually got.
|
||||
polls: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
@@ -355,7 +354,7 @@ fn service_probe(
|
||||
// the guid the wire addresses.
|
||||
mut merchant: ResMut<MerchantOpen>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
items: Res<crate::items::Items>,
|
||||
objects: crate::net::Objects,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
self_player: Query<(), With<SelfPlayer>>,
|
||||
player: Res<Player>,
|
||||
@@ -426,7 +425,7 @@ fn service_probe(
|
||||
// frames a second this leg never really looked (0713/0777/1355).
|
||||
true =>
|
||||
" — THE OBSERVER WAS STARVED, so this SKIP says nothing about \
|
||||
the NPC. Re-run on an idle machine (`uptime`; leg.sh's guard \
|
||||
the NPC. Re-run on an idle machine (`uptime`; a leg runner's guard \
|
||||
is load < 3).",
|
||||
false =>
|
||||
" — the machine was keeping up, so the `.go` was refused, the \
|
||||
@@ -759,7 +758,7 @@ fn service_probe(
|
||||
let Some(item_guid) = self_store
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|s| crate::ui_items::slot_guid(&s.0, held.bag, slot0, &items))
|
||||
.and_then(|s| crate::ui_items::slot_guid(&s.0, held.bag, slot0, &objects))
|
||||
else {
|
||||
error!(
|
||||
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — bag {} slot {} is on the \
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//!
|
||||
//! **Why a probe and not a capture:** none of this is a picture. Every reading here is a number or
|
||||
//! a Lua truth value — the queued area id, `IsInMeetingStoneQueue()`, whether the stock minimap
|
||||
//! button is shown — so the verdict needs no eye in the loop (`method.md` step 4).
|
||||
//! button is shown — so the verdict needs no eye in the loop (`docs/METHOD.md` step 4).
|
||||
//!
|
||||
//! **Where it enters the chain, exactly.** It writes the very
|
||||
//! [`MeetingStoneUse`] the click ladder writes, so everything **downstream** of that seam runs
|
||||
@@ -44,8 +44,8 @@
|
||||
//! WOW_NOSOUND=1 WOW_UNATTENDED=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
|
||||
//! WOW_PROBE_STONE=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
|
||||
//! "The local vmangos server"). `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]` aims it elsewhere.
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]` aims it elsewhere.
|
||||
//!
|
||||
//! **It leaves the probe body at the stone's minimum level**, deliberately and without putting it
|
||||
//! back: the level is the probe's own instrument (leg 2 needs one side of the band and leg 3 the
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
//! arrival distance to the destination node's DBC position, and measured flight duration vs the
|
||||
//! DBC prediction `Σ path-segment length ÷ 32 yd/s` (`PLAYER_FLIGHT_SPEED`) — timing measured,
|
||||
//! never eyeballed (decision 0404). An outer `timeout`d run + grep is the whole harness.
|
||||
//! Non-combat. Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
|
||||
//! WOW_CHAR=Probe<N-spelled>`; method.md "The local vmangos server"). Uses `.taxicheat on` so
|
||||
//! Non-combat. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill). Uses `.taxicheat on` so
|
||||
//! the fresh probe character can fly to an unvisited node (and so the SHOWTAXINODES mask
|
||||
//! exercises the full-network branch); the 110-copper fare is DB-seeded (see the Wait phase —
|
||||
//! `.modify money` outranks the probe account).
|
||||
@@ -95,7 +94,7 @@ enum Phase {
|
||||
started_at: f64,
|
||||
last_report: f64,
|
||||
/// Latched true the first in-flight frame the anim pair reads right — rider base
|
||||
/// Mount(91), mount child base Fly(135) (RF-0057 `0x5fd19c` + the 0441 mount pin).
|
||||
/// Mount(91), mount child base Fly(135) (`0x5fd19c` + the 0441 mount pin).
|
||||
gait_ok: bool,
|
||||
/// The largest |flying pitch| (radians) seen on the SELF transform mid-flight — the
|
||||
/// `sample_splines` tangent-climb attitude (decisions 0501/0516). The route climbs
|
||||
@@ -308,7 +307,7 @@ fn taxi_probe(
|
||||
} => {
|
||||
let wow = bevy_to_wow(player.pos);
|
||||
if player.server_riding() {
|
||||
// The anim pair the flight must show (RF-0057 `0x5fd19c` + the 0441 mount pin):
|
||||
// The anim pair the flight must show (`0x5fd19c` + the 0441 mount pin):
|
||||
// rider base Mount(91), mount child base Fly(135). Latched — the first frames
|
||||
// legitimately lag (mount attach, first selection).
|
||||
let rider = drivers.get(self_entity).ok().map(|d| d.playing().0);
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone WOW_UNATTENDED=1 WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_VENDOR_SWAP=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — method.md "The local vmangos server").
|
||||
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! the body saying what the wire asked for, what we resolved, and what is actually hanging off the
|
||||
//! skeleton right now.
|
||||
//!
|
||||
//! It exists because those are three different things and a screenshot conflates all three. B123 —
|
||||
//! *"show helm and cloak preferences are not taken into account"* — is exactly a gap between the
|
||||
//! It exists because those are three different things and a screenshot conflates all three. A
|
||||
//! helm or cloak the player chose to hide, drawn anyway, is exactly a gap between the
|
||||
//! first and the third: the descriptor carried `PLAYER_FLAGS_HIDE_HELM`, the resolver ignored it,
|
||||
//! and a helm model hung off the head. Nothing in a picture separates that from "this character
|
||||
//! simply has a helm equipped", which is why the report needed a reader rather than an eye.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
//! - **`terrain`** is the MCNK height under it, which is what the clamp finds when a building's own
|
||||
//! collider has not attached yet.
|
||||
//!
|
||||
//! Pair it with the slot-keyed probe identity and a `.go` to the reported spot:
|
||||
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` to the reported spot:
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
//! - **`meshes`** counts render descendants (the whole subtree, not just direct children — a GO
|
||||
//! model hangs its submeshes under an anim host).
|
||||
//!
|
||||
//! Pair it with the slot-keyed probe identity and a `.go` to the reported spot:
|
||||
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` to the reported spot:
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
|
||||
@@ -125,9 +125,9 @@ fn fire_lift_census(
|
||||
for (guid, net, t, store, transport, anchor, seed, vis, inherited, kids, attached) in &entities
|
||||
{
|
||||
let go_type = store.0.gameobject_type_id();
|
||||
// Every ticking GO type (the reference's own RF-0051 pair) plus anything already wearing
|
||||
// a transport component — so a type field we misread still shows up rather than vanishing
|
||||
// from the instrument that exists to find it.
|
||||
// Every ticking GO type (the pair the reference's per-frame tick `0x630970` fires) plus
|
||||
// anything already wearing a transport component — so a type field we misread still shows
|
||||
// up rather than vanishing from the instrument that exists to find it.
|
||||
if !(net.kind == EntityKind::GameObject && matches!(go_type, 11 | 15)
|
||||
|| transport.is_some()
|
||||
|| anchor.is_some()
|
||||
|
||||
@@ -272,7 +272,7 @@ fn drive_live_fps(
|
||||
// settle window on top — a run still worldless this far past it is stranded on a
|
||||
// glue screen (dead server, refused login the marker arms didn't catch) and every
|
||||
// second more is the 1371 sitting's dead wall-clock again. `FATAL` is the marker
|
||||
// leg.sh keys on.
|
||||
// a leg runner keys on.
|
||||
if time.elapsed_secs() > probe.at + BOOT_DEADLINE_SECS {
|
||||
error!(
|
||||
"live-fps: FATAL — still not in world {:.0}s past the probe delay; a measurement run with no world is dead. exiting",
|
||||
|
||||
@@ -89,7 +89,7 @@ fn fire_particle_census(
|
||||
// included — has already been discarded by the wall. Before 0678 it was routinely non-zero,
|
||||
// because `doodad_fade_alpha` admits any owner over `NEVER_FADE_RADIUS` at *every* distance
|
||||
// and nothing else bounded depth; that is precisely "all effects render at unlimited
|
||||
// distance", and precisely the reporter's "the terrain is not even rendered that far".
|
||||
// distance", out past the point where the terrain itself stops drawing.
|
||||
// It must now be **0**: past the wall the gate hides the emitter and freezes its pool.
|
||||
//
|
||||
// `beyond_wall` (verdict ignored) stays as the denominator — emitters *exist* out there and
|
||||
|
||||
@@ -8,7 +8,7 @@ use bevy::prelude::*;
|
||||
use super::ProbeClock;
|
||||
|
||||
/// Keep a probe run's window **un-occludable, and out of the director's way** — the one defence
|
||||
/// against macOS's ~1 fps throttle for a fully covered window (decisions 0713/0777, method.md's
|
||||
/// against macOS's ~1 fps throttle for a fully covered window (decisions 0713/0777, docs/METHOD.md's
|
||||
/// `caffeinate` note), at the smallest footprint that still buys it.
|
||||
///
|
||||
/// The on-top half used to live inside the FPS probe alone, which reads as "a frame-rate concern".
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
//! Off a transport `deck=` reads `-` and the deck columns are the world ones, which is the control:
|
||||
//! this census must not change on the ground, where a streak *should* smear behind a moving host.
|
||||
//!
|
||||
//! Pair it with the slot-keyed probe identity and a `.go` onto the Thunder Bluff lift:
|
||||
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` onto the Thunder Bluff lift:
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
//! never to be confused with one that built nothing, which is the distinction the census exists
|
||||
//! to keep.
|
||||
//!
|
||||
//! Pair it with the slot-keyed probe identity and a `.go` to the reported spot:
|
||||
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` to the reported spot:
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
|
||||
|
||||
@@ -1441,7 +1441,7 @@ pub(super) const SCAR_LOOK: [f32; 3] = [-11792.7, -2647.1, 66.6];
|
||||
/// **Forgetting it did not fail.** The run brought up the world with no in-game UI, the fixture's
|
||||
/// seed hit a nil global (`ToggleQuestLog`), logged ONE `warn!` into ~100 lines of pipeline
|
||||
/// chatter, wrote a perfectly valid PNG of a UI-less world, and exited **0**. That is an
|
||||
/// instrument that manufactures a confident false negative — the shape `method.md` §6 spends a
|
||||
/// instrument that manufactures a confident false negative — the shape `docs/METHOD.md` §6 spends a
|
||||
/// paragraph on — and it cost a session a wasted build and nearly a wrong conclusion about a fix
|
||||
/// that was in fact working.
|
||||
///
|
||||
|
||||
@@ -326,7 +326,7 @@ impl CreateSelection {
|
||||
}
|
||||
|
||||
/// One clickable control on the screen — a single component so one query dispatches every button.
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum CreateAction {
|
||||
Race(u8),
|
||||
Gender(u8),
|
||||
@@ -372,8 +372,8 @@ fn class_file(class: u8) -> &'static str {
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The name box's caret blink clock (the ref's `blinkSpeed`, f32 default 0.5 s — wow-re
|
||||
/// `rf82-editbox-runtime.md`, period `E+0x370` / accumulator `E+0x374`), reset on every keystroke
|
||||
/// The name box's caret blink clock (the ref's `blinkSpeed`, f32 default 0.5 s — `0x77a790`,
|
||||
/// period `E+0x370` / accumulator `E+0x374`), reset on every keystroke
|
||||
/// so the caret is solid while you type.
|
||||
///
|
||||
/// Its own resource rather than a [`CreateSelection`] field on purpose: ticking it there would trip
|
||||
@@ -808,4 +808,118 @@ mod tests {
|
||||
"the booth look must differ by class, or the preview cannot re-dress"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The chosen race / gender / class icon is LOCK-HIGHLIGHTED** — the reference's own verb:
|
||||
/// `SetCharacterRace`, `SetCharacterClass` and `SetCharacterGender` each call
|
||||
/// `button:LockHighlight()` on the one that is chosen and `UnlockHighlight()` on the rest
|
||||
/// (`CharacterCreate.lua` l.171/254/326). In 1.12 that lock *is* the whole selected visual:
|
||||
/// `CharacterCreateIconButtonTemplate` has its `<CheckedTexture>` commented out, leaving the
|
||||
/// ADD `ButtonHilight-Square` and the HIGHLIGHT-layer `$parentHighlightText` — both of which
|
||||
/// the lock is what lights.
|
||||
///
|
||||
/// **The regression this exists for** (director, 2026-09-17: *"it doesn't show that mage is
|
||||
/// selected"*): 2072 moved the sheen to one owner and gave this screen's visuals query a
|
||||
/// `&mut LockHighlight` term — but [`crate::glue::widgets::icon_button`], the only spawn site
|
||||
/// for all eighteen of these icons, inserted no such component. A `&mut T` term is a filter,
|
||||
/// so the query matched **nothing**: no selected sheen and no icon name on race, gender or
|
||||
/// class, for ten days. Every existing sheen test hand-spawned the flag, so the consumer was
|
||||
/// covered and the producer was not — hence this one builds the icons with the real
|
||||
/// `icon_button` and runs the real system.
|
||||
#[test]
|
||||
fn the_chosen_icons_are_lock_highlighted() {
|
||||
use crate::glue::art::GlueArt;
|
||||
use crate::glue::widgets::{icon_button, LockHighlight};
|
||||
|
||||
fn spawn_icons(mut commands: Commands, art: Res<GlueArt>) {
|
||||
let font = Handle::<Font>::default();
|
||||
commands.spawn(Node::default()).with_children(|p| {
|
||||
for race in ALLIANCE {
|
||||
icon_button(
|
||||
p,
|
||||
&font,
|
||||
CreateAction::Race(race),
|
||||
None::<parts::DynIcon>,
|
||||
None,
|
||||
None::<parts::DynText>,
|
||||
"",
|
||||
&art,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
for sex in 0..2u8 {
|
||||
icon_button(
|
||||
p,
|
||||
&font,
|
||||
CreateAction::Gender(sex),
|
||||
None::<parts::DynIcon>,
|
||||
None,
|
||||
None::<parts::DynText>,
|
||||
"",
|
||||
&art,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
for slot in 0..8u8 {
|
||||
icon_button(
|
||||
p,
|
||||
&font,
|
||||
CreateAction::ClassSlot(slot),
|
||||
None::<parts::DynIcon>,
|
||||
None,
|
||||
None::<parts::DynText>,
|
||||
"",
|
||||
&art,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut app = App::new();
|
||||
app.init_resource::<GlueArt>()
|
||||
// Human, female, MAGE — the director's own case. With no catalog loaded
|
||||
// `race_classes` is the full list, so mage (8) is slot 6.
|
||||
.insert_resource(CreateSelection {
|
||||
race: 1,
|
||||
sex: 1,
|
||||
class: 8,
|
||||
..default()
|
||||
})
|
||||
.add_systems(Startup, spawn_icons)
|
||||
.add_systems(Update, refresh::refresh_hover);
|
||||
app.update();
|
||||
|
||||
let locked: Vec<CreateAction> = app
|
||||
.world_mut()
|
||||
.query::<(&CreateAction, &LockHighlight)>()
|
||||
.iter(app.world())
|
||||
.filter(|(_, l)| l.0)
|
||||
.map(|(a, _)| *a)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
locked.len(),
|
||||
3,
|
||||
"exactly one race, one gender and one class icon is locked — got {locked:?}"
|
||||
);
|
||||
assert!(locked.contains(&CreateAction::Race(1)), "Human");
|
||||
assert!(locked.contains(&CreateAction::Gender(1)), "female");
|
||||
assert!(
|
||||
locked.contains(&CreateAction::ClassSlot(6)),
|
||||
"mage is slot 6 of [1,2,3,4,5,7,8,9,11] — and it is the mage icon the director saw \
|
||||
unmarked"
|
||||
);
|
||||
|
||||
// …and the selection moving takes the lock with it, rather than lighting a second icon.
|
||||
app.world_mut().resource_mut::<CreateSelection>().class = 1; // warrior, slot 0
|
||||
app.update();
|
||||
let locked: Vec<CreateAction> = app
|
||||
.world_mut()
|
||||
.query::<(&CreateAction, &LockHighlight)>()
|
||||
.iter(app.world())
|
||||
.filter(|(_, l)| l.0)
|
||||
.map(|(a, _)| *a)
|
||||
.collect();
|
||||
assert!(locked.contains(&CreateAction::ClassSlot(0)));
|
||||
assert!(!locked.contains(&CreateAction::ClassSlot(6)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,11 +219,17 @@ pub(super) fn refresh_dynamic(
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame interaction visuals the CREATE screen owns: highlight overlays (hover — and held
|
||||
/// while selected, the ref's `LockHighlight`), hover labels, and the Create button's disabled
|
||||
/// latch while a create is in flight. The screen-agnostic passes — up/down art swaps, the glue
|
||||
/// buttons' art + caption color, outline mirroring — are [`crate::glue`]'s, registered beside
|
||||
/// this in the plugin chain.
|
||||
/// Per-frame interaction visuals the CREATE screen owns: which icon is *chosen* (the ref's
|
||||
/// `LockHighlight`, which [`crate::glue::glue_hilights`] then renders), hover labels, the no-art
|
||||
/// fallback shade, and the Create button's disabled latch while a create is in flight. The
|
||||
/// screen-agnostic passes — up/down art swaps, the glue buttons' art + caption color, outline
|
||||
/// mirroring — are [`crate::glue`]'s, registered beside this in the plugin chain.
|
||||
///
|
||||
/// **The chosen-icon write is its own query, deliberately.** It used to be a sixth term on the
|
||||
/// hover query below, which meant a button without a [`LockHighlight`] dropped out of the hover
|
||||
/// visuals too — and since no spawn site on this screen had one, that query matched *nothing*:
|
||||
/// no selected sheen and no icon name anywhere on the create screen. Two jobs, two queries, so a
|
||||
/// missing component can only ever cost its own job.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_hover(
|
||||
sel: Res<CreateSelection>,
|
||||
@@ -238,10 +244,10 @@ pub(super) fn refresh_hover(
|
||||
&Children,
|
||||
&mut BackgroundColor,
|
||||
Has<FallbackFace>,
|
||||
&mut LockHighlight,
|
||||
),
|
||||
(With<Button>, Without<crate::glue::widgets::GlueBtn>),
|
||||
>,
|
||||
mut locks: Query<(&CreateAction, &mut LockHighlight)>,
|
||||
mut labels: Query<&mut Visibility, (With<HoverLabel>, Without<Hilight>)>,
|
||||
mut disables: Query<(&CreateAction, &mut GlueDisabled)>,
|
||||
) {
|
||||
@@ -254,14 +260,20 @@ pub(super) fn refresh_hover(
|
||||
_ => false,
|
||||
};
|
||||
|
||||
for (action, interaction, children, mut bg, fallback, mut locked) in &mut buttons {
|
||||
// `SetCharacterRace`/`SetCharacterClass`/`SetCharacterGender`: `LockHighlight()` on the one
|
||||
// that is chosen, `UnlockHighlight()` on the rest. Nothing about visibility — the sheen is
|
||||
// `crate::glue::glue_hilights`' alone.
|
||||
for (action, mut locked) in &mut locks {
|
||||
let is_sel = selected(action);
|
||||
let hovered = *interaction != Interaction::None;
|
||||
let lit = is_sel || hovered;
|
||||
// The sheen itself is `crate::glue::glue_hilights`' — this says only which row is chosen.
|
||||
if locked.0 != is_sel {
|
||||
locked.0 = is_sel;
|
||||
}
|
||||
}
|
||||
|
||||
for (action, interaction, children, mut bg, fallback) in &mut buttons {
|
||||
let is_sel = selected(action);
|
||||
let hovered = *interaction != Interaction::None;
|
||||
let lit = is_sel || hovered;
|
||||
// No-art fallback only: buttons spawned with a plain face get a hover shade. (Every node
|
||||
// *has* a `BackgroundColor` — only a `FallbackFace`'s belongs to us.)
|
||||
if fallback {
|
||||
|
||||
@@ -38,9 +38,12 @@
|
||||
//!
|
||||
//! At character select no addon has been *loaded* — `load_third_party` is a world-entry step
|
||||
//! (1191 §2) — so `GetNumAddOns()` would answer 0. The screen asks
|
||||
//! [`crate::ui_script::addons::installed`] instead: the same discovery, the same manifest parser,
|
||||
//! the same enable files. Two views of one folder, never two folders. With the dropdown that read
|
||||
//! happens **once per roster character at open** ([`AddonsPanel::open_for`]).
|
||||
//! [`crate::ui_script::addons::installed_rows`] instead, and resolves the enable bits through
|
||||
//! [`crate::ui_script::addons::EnableStore`] exactly as the world-entry walk does: the same
|
||||
//! discovery, the same manifest parser, the same store. Two views of one folder, never two
|
||||
//! folders. Both reads happen **once at open** ([`AddonsPanel::open_for`]) — the folder walk used
|
||||
//! to run once per roster character, which decision 2311 retired along with the per-character
|
||||
//! `installed()` it was there to feed.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui_render::ui_material::MaterialNode;
|
||||
@@ -224,41 +227,34 @@ impl Default for AddonsPanel {
|
||||
}
|
||||
|
||||
impl AddonsPanel {
|
||||
/// Open for this realm's roster, reading the folder fresh — once per character, so each
|
||||
/// staged column is that character's own enable file applied to one shared list. The
|
||||
/// reference's `AddonList_OnShow` also re-reads (`AddonList_Update`), so a folder that
|
||||
/// changed under us is picked up.
|
||||
/// Open for this realm's roster: the folder read **once** into one metadata list, and one
|
||||
/// staged column per character off the enable store. The reference's `AddonList_OnShow` also
|
||||
/// re-reads (`AddonList_Update`), so a folder that changed under us is picked up.
|
||||
///
|
||||
/// **The store is loaded for the whole roster, not per character** (decision 2311). A column
|
||||
/// is not "that character's file, everything else enabled": an addon the character has no row
|
||||
/// for takes what the realm's *other* characters agree on, and only falls to the manifest's
|
||||
/// `## DefaultState` when they disagree or nobody has said anything. Reading it the other way
|
||||
/// is what made a newly created character re-enable every addon in the All view — its
|
||||
/// all-enabled column turned every disabled row Mixed, and Mixed reads as on.
|
||||
pub(super) fn open_for(&mut self, realm: String, chars: Vec<String>) {
|
||||
self.list.clear();
|
||||
self.list = addons::installed_rows();
|
||||
self.staged.clear();
|
||||
if chars.is_empty() {
|
||||
// A fresh account: no character means no enable file to key. `None` is the "nobody's
|
||||
// file" read (everything enabled), staged into one anonymous column so the boxes
|
||||
// still toggle; Okay has no file to write ([`Self::save_staged`] walks `chars`).
|
||||
self.list = addons::installed(None);
|
||||
self.staged
|
||||
.push(self.list.iter().map(|a| a.enabled).collect());
|
||||
let store = addons::EnableStore::load(&realm, &chars);
|
||||
// A fresh account stages one anonymous column: no character means no file to key, and
|
||||
// Okay has none to write ([`Self::save_staged`] walks `chars`).
|
||||
let columns: Vec<Option<&str>> = if chars.is_empty() {
|
||||
vec![None]
|
||||
} else {
|
||||
for (c, name) in chars.iter().enumerate() {
|
||||
let id = (realm.clone(), name.clone());
|
||||
let rows = addons::installed(Some(&id));
|
||||
if c == 0 {
|
||||
self.staged.push(rows.iter().map(|a| a.enabled).collect());
|
||||
self.list = rows;
|
||||
} else {
|
||||
// Same folder, same deterministic discovery order, read back-to-back:
|
||||
// every call answers the same rows in the same order, only the `enabled` bits
|
||||
// differ. That is what lets ONE metadata list carry N columns. The order is
|
||||
// `addons::sort_by_directory_order` — NTFS's, the reference's own listing
|
||||
// order — and the point here is only that it is a pure function of the names.
|
||||
debug_assert_eq!(
|
||||
rows.len(),
|
||||
self.list.len(),
|
||||
"installed() must be deterministic across per-character reads"
|
||||
);
|
||||
self.staged.push(rows.iter().map(|a| a.enabled).collect());
|
||||
}
|
||||
}
|
||||
chars.iter().map(|c| Some(c.as_str())).collect()
|
||||
};
|
||||
for character in columns {
|
||||
self.staged.push(
|
||||
self.list
|
||||
.iter()
|
||||
.map(|a| store.enabled_for(&a.name, a.default_state, character))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
self.baseline = self.staged.clone();
|
||||
self.realm = realm;
|
||||
@@ -316,7 +312,7 @@ impl AddonsPanel {
|
||||
/// actually matters.
|
||||
pub(super) fn any_installed() -> bool {
|
||||
static ANY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ANY.get_or_init(|| !addons::installed(None).is_empty())
|
||||
*ANY.get_or_init(|| !addons::installed_rows().is_empty())
|
||||
}
|
||||
|
||||
/// The rows currently visible, as `(index, addon)` — enable state is the view's to answer
|
||||
@@ -392,10 +388,12 @@ impl AddonsPanel {
|
||||
}
|
||||
|
||||
/// Is this addon enabled *for the current view* — the bit the gate is fed? A single character
|
||||
/// reads their own column. The All view answers **"any staged character has it on"** — OURS
|
||||
/// (1293): the reference feeds `AddOn_CanLoad` a NULL character there and that read's exact
|
||||
/// semantics are un-carved, so we state the natural reading rather than guess at bytes nobody
|
||||
/// has verified.
|
||||
/// reads their own column. The All view answers **"any staged character has it on"**, which
|
||||
/// 1293 stated as the natural reading of an untraced mechanism and which the bytes have
|
||||
/// since confirmed: `AddonList_Update` computes `enabled = (checkboxState > 0)` off
|
||||
/// `GetAddOnEnableState(nil, i)`, whose `1` (enabled for some) therefore counts as on
|
||||
/// (`0x51e470`). What 1293 got wrong was not this fold but what each column holds — see
|
||||
/// [`Self::open_for`] and decision 2311.
|
||||
fn effective_enabled(&self, i: usize) -> bool {
|
||||
self.box_state(i) != BoxState::Off
|
||||
}
|
||||
@@ -606,7 +604,7 @@ pub(super) fn drive_addons_panel(
|
||||
for (entity, action, interaction) in &hovers {
|
||||
// **The scroll bar warps on the PRESS**, and only it: the reference's `CSimpleSlider`
|
||||
// OnMouseDown (`0x789ca0`, 45 bytes, zero branches) warps the value from any press inside
|
||||
// the hit rect — there is no thumb hit-test in the class (wow-re `ui.md`). Every *button*
|
||||
// the hit rect — there is no thumb hit-test in the class. Every *button*
|
||||
// in this panel fires on the RELEASE, over the button that took the press (1533).
|
||||
let click = if *action == AddonsAction::ScrollBar {
|
||||
interaction.is_changed() && *interaction == Interaction::Pressed
|
||||
@@ -694,7 +692,7 @@ pub(super) fn drive_addons_panel(
|
||||
}
|
||||
}
|
||||
|
||||
// The live `checkAddonVersion` mirror (1293 §5): read per frame like the gate's own per-query
|
||||
// The live `checkAddonVersion` mirror (1293): read per frame like the gate's own per-query
|
||||
// read (1292 §2.2), so the ForceLoad click above — or any other writer — repaints the
|
||||
// statuses this same frame with nothing rescanned. Absent VM or table = the registrar
|
||||
// default: check ON.
|
||||
@@ -1712,7 +1710,7 @@ mod tests {
|
||||
dependencies: deps.iter().map(|d| (*d).to_string()).collect(),
|
||||
interface: iface,
|
||||
load_on_demand: false,
|
||||
enabled: true,
|
||||
default_state: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1721,7 +1719,7 @@ mod tests {
|
||||
/// `p.view = Some(c)` themselves.
|
||||
fn panel_for(chars: usize, list: Vec<InstalledAddOn>) -> AddonsPanel {
|
||||
let staged: Vec<Vec<bool>> = (0..chars.max(1))
|
||||
.map(|_| list.iter().map(|a| a.enabled).collect())
|
||||
.map(|_| list.iter().map(|a| a.default_state).collect())
|
||||
.collect();
|
||||
AddonsPanel {
|
||||
open: true,
|
||||
@@ -1848,7 +1846,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The All view's gate input is OUR carve (1293): "enabled = any staged character has it on".
|
||||
/// The All view's gate input is OUR reading (1293): "enabled = any staged character has it on".
|
||||
#[test]
|
||||
fn the_all_view_feeds_the_gate_any_character_on() {
|
||||
let mut p = panel_for(
|
||||
@@ -1934,7 +1932,7 @@ mod tests {
|
||||
assert_eq!(p.staged, vec![vec![true, true]]);
|
||||
p.staged[0][0] = false;
|
||||
assert!(
|
||||
p.list[0].enabled,
|
||||
p.list[0].default_state,
|
||||
"the read-in list is never mutated — only `staged` is, which is what makes Cancel free"
|
||||
);
|
||||
p.close();
|
||||
@@ -1982,27 +1980,151 @@ mod tests {
|
||||
p.staged[1][1] = false;
|
||||
p.save_staged();
|
||||
|
||||
// Read back through the same folder view the world-entry walk uses — the two views of
|
||||
// one folder that 1197 §3 demands can never disagree.
|
||||
let alice = ("TestRealm".to_string(), "Alice".to_string());
|
||||
let bob = ("TestRealm".to_string(), "Bob".to_string());
|
||||
let carol = ("TestRealm".to_string(), "Carol".to_string());
|
||||
let read = |id: &(String, String)| -> Vec<bool> {
|
||||
addons::installed(Some(id))
|
||||
.iter()
|
||||
.map(|a| a.enabled)
|
||||
// Read back through the same store the world-entry walk resolves against — the two views
|
||||
// of one folder that 1197 §3 demands can never disagree.
|
||||
let roster = ["Alice".to_string(), "Bob".to_string(), "Carol".to_string()];
|
||||
let store = addons::EnableStore::load("TestRealm", &roster);
|
||||
let rows = addons::installed_rows();
|
||||
let read = |who: &str| -> Vec<bool> {
|
||||
rows.iter()
|
||||
.map(|a| store.enabled_for(&a.name, a.default_state, Some(who)))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
read(&alice),
|
||||
read("Alice"),
|
||||
vec![false, true],
|
||||
"Alice's file carries HER edit"
|
||||
);
|
||||
assert_eq!(read(&bob), vec![true, false], "Bob's carries HIS");
|
||||
assert_eq!(read("Bob"), vec![true, false], "Bob's carries HIS");
|
||||
let carol = ("TestRealm".to_string(), "Carol".to_string());
|
||||
assert!(
|
||||
!addons::enable_state_path(Some(&carol)).unwrap().exists(),
|
||||
"an unchanged column writes no file — only the diffs against the open-time baseline"
|
||||
);
|
||||
// And Carol, who has no file at all, is NOT "everything enabled": she takes what the
|
||||
// others agree on, which here is nothing — Alpha and Beta are each on for one and off
|
||||
// for the other — so both fall to their manifests' `## DefaultState` (2311).
|
||||
assert_eq!(read("Carol"), vec![true, true]);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// **Creating a character does not re-enable everything** — the director's report,
|
||||
/// 2026-09-17: *"after creating a new char all addons are enabled again, even after disabling
|
||||
/// all of them just before"*.
|
||||
///
|
||||
/// The walk is theirs, verbatim: Disable All in the All view, Okay, create a character, open
|
||||
/// the list again. What used to happen is the whole bug in one line — the new character had
|
||||
/// no `AddOns.txt`, benilla read an absent row as **enabled**, and that all-true column turned
|
||||
/// every row in the All view from Off to Mixed, which paints a check and reads as on
|
||||
/// (`effective_enabled`). One click there would then have written the all-enabled state back
|
||||
/// onto every other character's file: a one-way ratchet.
|
||||
///
|
||||
/// The reference does not do this: its enable query `0x51e470` counts only nodes with an
|
||||
/// **explicit** entry, so a character with no file contributes no opinion and inherits the
|
||||
/// aggregate — here, the unanimous `disabled` the player just saved. Decision 2311.
|
||||
#[test]
|
||||
fn a_new_character_inherits_the_disable_it_did_not_ask_for() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp =
|
||||
std::env::temp_dir().join(format!("benilla-charsel-newchar-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let home = tmp.join("benilla-config");
|
||||
for name in ["Alpha", "Beta"] {
|
||||
let dir = home.join("AddOns").join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.toc")), "## Interface: 11200\n").unwrap();
|
||||
}
|
||||
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let _h =
|
||||
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
|
||||
|
||||
// The roster before the create, and the player's Disable All + Okay over it.
|
||||
let mut p = AddonsPanel::default();
|
||||
p.open_for(
|
||||
"TestRealm".into(),
|
||||
vec!["Onemage".into(), "Onerogue".into()],
|
||||
);
|
||||
p.set_all(false);
|
||||
p.save_staged();
|
||||
p.close();
|
||||
|
||||
// …then a character is created, so the roster the panel opens over has one more name and
|
||||
// that name has no enable file at all.
|
||||
p.open_for(
|
||||
"TestRealm".into(),
|
||||
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
|
||||
);
|
||||
assert!(p.view.is_none(), "reopens on All, the reference's default");
|
||||
assert_eq!(p.staged.len(), 3);
|
||||
assert_eq!(
|
||||
p.staged[2],
|
||||
vec![false, false],
|
||||
"the new character inherits the unanimous disable, not a blank slate"
|
||||
);
|
||||
for i in 0..p.list.len() {
|
||||
assert_eq!(
|
||||
p.box_state(i),
|
||||
BoxState::Off,
|
||||
"row {i} reads OFF in the All view — Mixed here is the bug, and it paints a check"
|
||||
);
|
||||
assert!(!p.effective_enabled(i), "…so nothing is loadable either");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// The other side of the same law: a new character does **not** inherit a disable the roster
|
||||
/// never agreed on. One character off, one on, and the newcomer takes the manifest's
|
||||
/// `## DefaultState` — so this is not "new characters copy whoever is strictest".
|
||||
#[test]
|
||||
fn a_new_character_inherits_nothing_when_the_roster_disagrees() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp =
|
||||
std::env::temp_dir().join(format!("benilla-charsel-split-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let home = tmp.join("benilla-config");
|
||||
for name in ["Alpha", "Beta"] {
|
||||
let dir = home.join("AddOns").join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("{name}.toc")),
|
||||
// Beta ships `## DefaultState: disabled`, so the tie-break is visible.
|
||||
if name == "Beta" {
|
||||
"## Interface: 11200\n## DefaultState: disabled\n"
|
||||
} else {
|
||||
"## Interface: 11200\n"
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let _h =
|
||||
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
|
||||
|
||||
let mut p = AddonsPanel::default();
|
||||
p.open_for(
|
||||
"TestRealm".into(),
|
||||
vec!["Onemage".into(), "Onerogue".into()],
|
||||
);
|
||||
p.view = Some(0);
|
||||
p.set_all(false); // the mage turns both off; the rogue keeps the defaults
|
||||
p.view = Some(1);
|
||||
p.set_all(true);
|
||||
p.save_staged();
|
||||
p.close();
|
||||
|
||||
p.open_for(
|
||||
"TestRealm".into(),
|
||||
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
|
||||
);
|
||||
assert_eq!(
|
||||
p.staged[2],
|
||||
vec![true, false],
|
||||
"split roster → each row falls to its own `## DefaultState`"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
@@ -2089,8 +2211,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A press on the TRACK — off the knob — warps the knob's centre under the cursor and drags
|
||||
/// on from there, one gesture. Byte-verified 1.12 (wow-re `slider-mouse-law.md` §1, and the
|
||||
/// director's own requirement in 0989); the glue bar runs the same law as every in-game one.
|
||||
/// on from there, one gesture. 1.12 does this (`0x789ca0`, and the director's own requirement
|
||||
/// in 0989); the glue bar runs the same law as every in-game one.
|
||||
#[test]
|
||||
fn a_press_on_the_bare_track_warps_the_knob_under_the_cursor() {
|
||||
let thumb_n = KNOB / BAR_H;
|
||||
|
||||
@@ -154,11 +154,14 @@ impl Plugin for CharSelectPlugin {
|
||||
input::rotate_model,
|
||||
debug_select_dialog,
|
||||
dialog::drive_delete_dialog,
|
||||
// Ahead of the dialog driver, so a refused delete is on screen the frame
|
||||
// its answer lands rather than the next.
|
||||
delete_result,
|
||||
// The reference's one `GlueDialog` (`crate::glue::dialog`), which on this
|
||||
// screen carries the refused character login. Both glue screens run the
|
||||
// same system over the same resource; what a press *means* is answered
|
||||
// per-screen, and an `Error` — the only kind reachable here — needs no
|
||||
// answer at all.
|
||||
// screen carries the refused character login and the refused delete. Both
|
||||
// glue screens run the same system over the same resource; what a press
|
||||
// *means* is answered per-screen, and an `Error` — the only kind reachable
|
||||
// here — needs no answer at all.
|
||||
crate::glue::dialog::drive_glue_dialog,
|
||||
// Before the list refresh, and before `select_input` reads a click that
|
||||
// landed on the panel rather than the screen (decision 1196).
|
||||
@@ -168,7 +171,6 @@ impl Plugin for CharSelectPlugin {
|
||||
refresh::refresh_list,
|
||||
refresh::refresh_banner_and_buttons,
|
||||
refresh::feed_glue_preview,
|
||||
delete_result,
|
||||
debug_select_shot,
|
||||
)
|
||||
.chain()
|
||||
@@ -210,16 +212,16 @@ pub(crate) struct Roster {
|
||||
///
|
||||
/// The ref's `SelectCharacter` zeroes the select facing **unconditionally**: `0x472950`'s
|
||||
/// `mov ds:0xb4217c, 0` sits one instruction *above* the already-built discriminator, so it
|
||||
/// dominates both legs, and the merged tail re-applies it geometrically (wow-re
|
||||
/// `glue/scratch/glue-preview-facing-law.md`, 1533). A counter rather than change-detection on
|
||||
/// `selected`, because the engine re-squares on a *re*-selection of the same index too — which
|
||||
/// is exactly what a roster refresh does, calling it with the index it already holds.
|
||||
/// dominates both legs, and the merged tail re-applies it geometrically (1533). A counter
|
||||
/// rather than change-detection on `selected`, because the engine re-squares on a
|
||||
/// *re*-selection of the same index too — which is exactly what a roster refresh does, calling
|
||||
/// it with the index it already holds.
|
||||
///
|
||||
/// **The click is the one caller that does not reach it**, and the gate is in the stock Lua
|
||||
/// rather than in the engine — see [`Roster::click_row`] and decision 2194. The caller census
|
||||
/// is wow-re `glue/scratch/select-character-caller-gate.md`: of the ten Lua call sites only the
|
||||
/// two click handlers are gated, and of `0x472740`'s four C callers two are the roster teardown
|
||||
/// passing `-1` (so `0x472950` exits above the reset) — every ungated path re-squares.
|
||||
/// rather than in the engine — see [`Roster::click_row`] and decision 2194. Of the ten Lua call
|
||||
/// sites only the two click handlers are gated, and of `0x472740`'s four C callers two are the
|
||||
/// roster teardown passing `-1` (so `0x472950` exits above the reset) — every ungated path
|
||||
/// re-squares.
|
||||
pub(super) select_seq: u64,
|
||||
/// The guid we answered the IO thread with; `Some` = a login is requested/live.
|
||||
pub(super) pending_pick: Option<u64>,
|
||||
@@ -303,9 +305,8 @@ impl Roster {
|
||||
/// So the row you are already on is never re-selected from a click, and the facing zero
|
||||
/// [`Self::select`] owes never fires for it: the angle you dragged the character to survives
|
||||
/// clicking it again. Decision 2194, correcting 1533 — the engine function is unconditional as
|
||||
/// recorded (verified again, three ways, in wow-re
|
||||
/// `glue/scratch/select-character-caller-gate.md`), but the click never reaches it: the binding
|
||||
/// has exactly one live call site, and the two handlers that lead to it both gate.
|
||||
/// recorded (`0x472740`), but the click never reaches it: the binding has exactly one live call
|
||||
/// site, and the two handlers that lead to it both gate.
|
||||
pub(super) fn click_row(&mut self, row: usize) {
|
||||
if self.selected != Some(row) {
|
||||
self.select(Some(row));
|
||||
@@ -374,7 +375,7 @@ impl Roster {
|
||||
|
||||
// ── The remembered character (`lastCharacterIndex`, decision 1622) ───────────────────────────────
|
||||
|
||||
/// The CVar the select screen remembers you by — a **real 1.12 CVar**, byte-verified in wow-re
|
||||
/// The CVar the select screen remembers you by — a **real 1.12 CVar**
|
||||
/// (registered at `0x402d93`, name `0x82e8f8`, help "Last character selected", default `"0"`,
|
||||
/// pointer cached at `[0x882674]`), and written engine-side: no shipped GlueXML names it and the
|
||||
/// binary never looks it up by name.
|
||||
@@ -579,7 +580,7 @@ fn apply_roster_policy(
|
||||
warn!("char select: WOW_CHAR={name} not on this account — showing roster");
|
||||
// A driverless run can never pick a different row itself — parked here it
|
||||
// burns its whole wall-clock. Same home as the login arm's verdict, so the
|
||||
// marker leg.sh greps for is written in exactly one place (1371, 1769).
|
||||
// marker a leg runner greps for is written in exactly one place (1371, 1769).
|
||||
if std::env::var_os("WOW_LOGIN_SMOKE").is_none()
|
||||
&& crate::run_mode::fatal_when_driverless(&format!(
|
||||
"WOW_CHAR={name} is not on this account"
|
||||
@@ -668,9 +669,8 @@ fn back_on_login_refused(
|
||||
/// **skipping `0x41`**, `CHAR_LOGIN_FAILED`, which is reachable only as the default. So the
|
||||
/// tempting `0x3d + byte` is right for four rows and wrong for the rest, and `0` and anything past
|
||||
/// `6` are "Login failed" rather than an out-of-bounds read: the `ja` guard precedes the table.
|
||||
/// (VERIFIED off `WoW.exe`, cross-checked — wow-5875-re
|
||||
/// `system/net/scratch/char-login-failed-law.md`. The strings are the shipped
|
||||
/// `GlueStrings.lua:158-166`, quoted here only as the graceful-absence fallback.)
|
||||
/// (The strings are the shipped `GlueStrings.lua:158-166`, quoted here only as the
|
||||
/// graceful-absence fallback.)
|
||||
///
|
||||
/// Both emulators speak this dialect. vmangos sends a bare `1` for all three of its refusal
|
||||
/// guards (`PlayerLoading() || GetPlayer() || !guid.IsPlayer()`), so **every** refusal from our
|
||||
@@ -723,19 +723,67 @@ fn back_on_logout(
|
||||
}
|
||||
}
|
||||
|
||||
/// Surface a refused delete (the roster refresh already reflects a success — the row vanishes).
|
||||
/// A refusal is realistically unreachable on vmangos (any enumerated character deletes), so a log
|
||||
/// line honest-flags it rather than growing an error dialog nothing can trigger.
|
||||
fn delete_result(mut msgs: MessageReader<CharActionResultMessage>) {
|
||||
/// Surface a refused delete in the glue dialog; a success needs nothing here, since `net::io`
|
||||
/// re-enumerates before the result and the row is already gone.
|
||||
///
|
||||
/// **A refusal is reachable, and was silent.** vmangos refuses to delete a guild master
|
||||
/// (`HandleCharDeleteOpcode`: `GetGuildByLeader` → `CHAR_DELETE_FAILED`), and this used to be a
|
||||
/// `warn!` under a comment calling the refusal unreachable — so the player pressed Delete, typed
|
||||
/// DELETE, and watched nothing happen.
|
||||
///
|
||||
/// The reference's shape, read off `WoW.exe`: `SMSG_CHAR_DELETE 0x5b45c0` hands the result byte to
|
||||
/// `0x5ab0e0`, which passes it **straight through** as the operation status
|
||||
/// (`SetStatus(byte == 0x39, byte)`); `CGlueMgr::Update`'s `screenState == 6` arm (`0x46c14e`)
|
||||
/// then, on a completed-and-failed operation, fires `OPEN_STATUS_DIALOG("OKAY", text)`
|
||||
/// (`0x46c177` pushes `"OKAY"` `0x837084`) with the status text the key table `0x85cae8` names —
|
||||
/// the one-button dialog [`crate::glue::dialog::GlueDialog::open_error`] is.
|
||||
fn delete_result(
|
||||
mut msgs: MessageReader<CharActionResultMessage>,
|
||||
mut dialog: ResMut<crate::glue::dialog::GlueDialog>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
) {
|
||||
let empty = GlueStrings::default();
|
||||
let strings = strings.as_deref().unwrap_or(&empty);
|
||||
for msg in msgs.read() {
|
||||
if msg.action == CharAction::Delete
|
||||
&& msg.code != benilla_protocol::messages::CHAR_DELETE_SUCCESS
|
||||
{
|
||||
warn!("char select: delete refused (code {:#04x})", msg.code);
|
||||
if msg.action != CharAction::Delete {
|
||||
continue;
|
||||
}
|
||||
if let Some(text) = char_delete_refusal_text(strings, msg.code) {
|
||||
info!(
|
||||
"char select: delete refused (code {:#04x}) — {text}",
|
||||
msg.code
|
||||
);
|
||||
dialog.open_error(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `SMSG_CHAR_DELETE` result byte → the sentence the refusal dialog says, or `None` for the
|
||||
/// success (`0x39`), which the refreshed roster says instead.
|
||||
///
|
||||
/// The keys are the `CHAR_DELETE_*` block of the reference's status-key table `0x85cae8`
|
||||
/// (`0x38..=0x3b`), numbered exactly as vmangos's `ResponseCodes` (`SharedDefines.h`). vmangos
|
||||
/// sends only two of them: `0x39` on success and `0x3a` for a guild master; its three other
|
||||
/// refusals (a character still loaded, one not found, one on another account) send nothing at all.
|
||||
///
|
||||
/// **The default arm is benilla's, not the reference's.** The reference indexes the whole 83-row
|
||||
/// table with the byte, so an out-of-block byte would read some other family's sentence (and one
|
||||
/// past `0x52` would read `"(%i)"`); that table is not transcribed here, and no server we speak to
|
||||
/// sends such a byte, so anything unnamed says the block's own generic failure.
|
||||
pub(crate) fn char_delete_refusal_text(strings: &GlueStrings, code: u8) -> Option<&str> {
|
||||
let (key, fallback): (&str, &str) = match code {
|
||||
benilla_protocol::messages::CHAR_DELETE_SUCCESS => return None,
|
||||
0x38 => ("CHAR_DELETE_IN_PROGRESS", "Deleting character"),
|
||||
0x3B => (
|
||||
"CHAR_DELETE_FAILED_LOCKED_FOR_TRANSFER",
|
||||
"Your character is currently locked as part of the paid character transfer process.",
|
||||
),
|
||||
// `0x3a` — the guild-master refusal — and the default arm above.
|
||||
_ => ("CHAR_DELETE_FAILED", "Character deletion failed"),
|
||||
};
|
||||
Some(strings.text(key, fallback))
|
||||
}
|
||||
|
||||
/// Glue-flow smoke (`WOW_GLUE_ROUNDTRIP=1`, decision 0423): once a real roster is up, bounce
|
||||
/// CharSelect → CharCreate → **Back** → CharSelect and exit — so the return-to-select rebuild is
|
||||
/// provable headlessly from the logs. Runs ungated (it crosses states); inert without the env.
|
||||
@@ -1388,6 +1436,73 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **A refused delete is said, not swallowed.** vmangos refuses to delete a guild master with
|
||||
/// `CHAR_DELETE_FAILED` (0x3a); the screen used to `warn!` into the log and show nothing, so
|
||||
/// the confirmed delete just silently did not happen. The reference raises its one-button
|
||||
/// glue dialog with the `CHAR_DELETE_*` status text (`CGlueMgr::Update`'s `screenState == 6`
|
||||
/// arm). A success raises nothing — the refreshed roster is the answer.
|
||||
#[test]
|
||||
fn a_refused_delete_raises_the_glue_dialog() {
|
||||
use crate::glue::dialog::{DialogKind, GlueDialog};
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_message::<CharActionResultMessage>()
|
||||
.init_resource::<GlueDialog>()
|
||||
.add_systems(Update, delete_result);
|
||||
|
||||
app.world_mut().write_message(CharActionResultMessage {
|
||||
action: CharAction::Delete,
|
||||
code: benilla_protocol::messages::CHAR_DELETE_SUCCESS,
|
||||
});
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<GlueDialog>().is_open(),
|
||||
"a success is answered by the roster, not a dialog"
|
||||
);
|
||||
|
||||
app.world_mut().write_message(CharActionResultMessage {
|
||||
action: CharAction::Delete,
|
||||
code: 0x3A,
|
||||
});
|
||||
app.update();
|
||||
let dialog = app.world().resource::<GlueDialog>();
|
||||
assert_eq!(dialog.kind, Some(DialogKind::Error), "the OKAY dialog");
|
||||
// No GlueStrings in this App: the fallback literal. The shipped sentence is asserted
|
||||
// against the real chain below.
|
||||
assert_eq!(dialog.text, "Character deletion failed");
|
||||
}
|
||||
|
||||
/// The delete refusals resolve to the sentences 1.12 ships, off the player's own chain — and
|
||||
/// every key named is a REAL key, so a matching fallback cannot hide a missing one. Skips
|
||||
/// without client data.
|
||||
#[test]
|
||||
fn every_delete_result_resolves_in_the_real_glue_strings() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let strings = crate::glue_strings::table_from_chain(&mut chain);
|
||||
|
||||
assert_eq!(char_delete_refusal_text(&strings, 0x39), None);
|
||||
assert_eq!(
|
||||
char_delete_refusal_text(&strings, 0x3A),
|
||||
Some("Character deletion failed")
|
||||
);
|
||||
assert_eq!(
|
||||
char_delete_refusal_text(&strings, 0x3B),
|
||||
Some(
|
||||
"Your character is currently locked as part of the paid character transfer \
|
||||
process."
|
||||
)
|
||||
);
|
||||
let map = strings.into_map();
|
||||
for key in [
|
||||
"CHAR_DELETE_IN_PROGRESS",
|
||||
"CHAR_DELETE_FAILED",
|
||||
"CHAR_DELETE_FAILED_LOCKED_FOR_TRANSFER",
|
||||
] {
|
||||
assert!(map.contains_key(key), "{key} is not in the shipped table");
|
||||
}
|
||||
}
|
||||
|
||||
/// B119 — a created character is selected against the roster **already in hand**. `net::io`
|
||||
/// re-enumerates and emits the fresh roster BEFORE the create result, so by the time
|
||||
/// `note_created` runs the row exists and the roster message is spent; a version that only armed
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! **Chat bubbles** — `CGChatBubbleFrame` (wow-re `object-layer/scratch/chat-bubble.md`,
|
||||
//! §5-verified 2026-07-11; decision 0288's phase-9 tail, landed by decision 0598): the
|
||||
//! over-the-head speech bubble a chat line spawns, a **2-D overlay** like the V-plate
|
||||
//! **Chat bubbles** — `CGChatBubbleFrame` (decision 0288's phase-9 tail, landed by decision
|
||||
//! 0598): the over-the-head speech bubble a chat line spawns, a **2-D overlay** like the V-plate
|
||||
//! ([`crate::vplates`]) and unlike the world-pass overhead *names* ([`crate::nameplates`]).
|
||||
//!
|
||||
//! The pinned law, transcribed:
|
||||
@@ -31,15 +30,14 @@
|
||||
//! the **MD20 header image**, i.e. the **Stand sequence CAaBox's Z extent**, a file constant
|
||||
//! with no bone matrix anywhere in its call tree — and the scaled product is **latched** at
|
||||
//! `bubble+0x354` behind a parity guard, so it is queried exactly **once per chat line**
|
||||
//! ([`crate::entities::StandBoxHeight`], wow-re's anchor cross-check 2026-08-17; 1406).
|
||||
//! ([`crate::entities::StandBoxHeight`], 1406).
|
||||
//! - **Stacking** (`0x4b1060`, the per-frame pass over ALL live bubbles — one caller, `0x4817a3`):
|
||||
//! each bubble is its own frame and its frame LEVEL is restamped every frame from the CAMERA's
|
||||
//! distance to the speaker. The list re-sorts farthest-first (`0x4b1360`, comparing
|
||||
//! `[bubble+0x350]` = |cam − unit|², written at `0x4b12c0`), then a head→tail walk stamps
|
||||
//! `SetFrameLevel(2), (3), (4)…` (`0x4b1309`). Level outranks draw layer in the client's total
|
||||
//! order, so overlapping bubbles stack as WHOLE cards with the nearest speaker's on top —
|
||||
//! nothing interleaves. ([`level_z`]; benilla read the bytes for this one, 1504 — wow-re's
|
||||
//! `chat-bubble.md` covers the spawn/geometry/anchor and never reached the manager tick.)
|
||||
//! nothing interleaves. ([`level_z`]; benilla read the bytes for this one, 1504.)
|
||||
//!
|
||||
//! Named divergences (all deliberate):
|
||||
//! - ~~**`ChatBubblesParty` defaults ON**~~ — **no longer a divergence (1804).** It shipped `"1"`
|
||||
@@ -48,8 +46,8 @@
|
||||
//! [`crate::vplates::VPlateMode`], whose enemy plates were the same shape of pin.
|
||||
//! - **The v1 kind set is SAY/YELL/PARTY + monster say/yell.** The byte gate is
|
||||
//! "sender resolves", not a type whitelist, which *implies* guild/officer/whisper/emote
|
||||
//! bubbles too — but that category claim is INFERRED on an OPEN wire-type remap
|
||||
//! (chat-bubble.md §1) and contradicts the remembered reference look, so the uncontested
|
||||
//! bubbles too — but that category claim is inferred, resting on an open wire-type remap
|
||||
//! (`0x49a870`), and contradicts the remembered reference look, so the uncontested
|
||||
//! set ships and a capture can widen it ([`bubble_cvar`]).
|
||||
//! - ~~**The anchor height is the posed overhead attachment**~~ — **REFUTED and removed (1406).**
|
||||
//! This shipped as an INFERRED equivalence ("both are the head-region attachment height,
|
||||
@@ -91,9 +89,9 @@ use benilla_world::view::WorldCamera;
|
||||
/// they were a pair of `const bool` from 0598 until the options window had a page to put
|
||||
/// them on, which is exactly the shape 1134 calls a row over a frozen gate.
|
||||
///
|
||||
/// **Both defaults are the binary's own** (`ChatBubbles` `"1"`, `ChatBubblesParty` `"0"` — wow-re
|
||||
/// `object-layer/scratch/chat-bubble.md`). `party` shipped ON from 0598 to 1804 on the director's
|
||||
/// `/p` ask; the row is on the Chat page, one click from where it was.
|
||||
/// **Both defaults are the binary's own** (`ChatBubbles` `"1"`, `ChatBubblesParty` `"0"`).
|
||||
/// `party` shipped ON from 0598 to 1804 on the director's `/p` ask; the row is on the Chat page,
|
||||
/// one click from where it was.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct BubbleConfig {
|
||||
/// `ChatBubbles` — say/yell and their monster variants.
|
||||
@@ -136,7 +134,7 @@ const BORDER_FRAC: f32 = 16.0 / 1024.0;
|
||||
/// over the frame's bottom edge piece: its art carries the border lines that make the seam read
|
||||
/// continuous, and the reference gets the same result from its own layer key (the tail is an
|
||||
/// ARTWORK texture, the text an ARTWORK font string, and a batch drains every texture before
|
||||
/// every font string — wow-re `ui/scratch/draw-order-law.md` §4).
|
||||
/// every font string — `0x76fb00`).
|
||||
const Z_BG: u64 = 0;
|
||||
const Z_EDGE: u64 = 1;
|
||||
const Z_TAIL: u64 = 2;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! Three consumers today and they are unrelated to each other — [`crate::ui_pet_book`] wants field
|
||||
//! 4's pet name token, `UnitHasRelicSlot` wants field 16's relic flag, and
|
||||
//! [`crate::spell_mods`] wants field 15's class spell-family — which is exactly why the
|
||||
//! `crate::spell::mods` wants field 15's class spell-family — which is exactly why the
|
||||
//! table does not live inside any of them. It used to live in the pet book, back when the pet
|
||||
//! token was the only column anyone read.
|
||||
//!
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
//!
|
||||
//! # What the reference does, and what we match
|
||||
//!
|
||||
//! Byte-verified in wow-re (the cinematic dispatch, 2026-08-29), and the reason each piece here is
|
||||
//! shaped the way it is:
|
||||
//! The reason each piece here is shaped the way it is:
|
||||
//!
|
||||
//! - **A trigger that arrives before the world is up is deferred, not dropped.** The reference
|
||||
//! stashes the sequence id in a single-slot latch (`0xc4d75c`) whenever its world-load gate is
|
||||
@@ -50,8 +49,7 @@
|
||||
//! its *completion callback*: `0x4c0d10` builds a fullscreen opaque-black quad (`0xff000000`) and
|
||||
//! latches what to run when it is fully up, so `CINEMATIC_START`, each shot advance and
|
||||
//! `EndCinematic` all execute at full black and `0x4c1280` fades back in. Six sites, both edges,
|
||||
//! always black, always 0.25 s (wow-re `ui/scratch/cinematic-camera-law.md` §3.7, a CORRECTION to
|
||||
//! the earlier "deferred by a delay" reading). There is **no audio fade anywhere on this path**.
|
||||
//! always black, always 0.25 s. There is **no audio fade anywhere on this path**.
|
||||
//!
|
||||
//! benilla plays and acks immediately. Building the picture is what would let the timing be
|
||||
//! faithful too, so the two go together and neither is faked without the other — decision 1724
|
||||
@@ -97,10 +95,10 @@ pub(crate) struct Cinematic {
|
||||
/// is **holding at black** until the world around the body is presentable again.
|
||||
///
|
||||
/// This is the reference's `0x48f080` — EndCinematic's forced terrain load back around the
|
||||
/// followed unit, which runs at full black and *blocks* (law §8.2, and §8.4: no loading cover
|
||||
/// is raised anywhere on this path). benilla streams asynchronously and cannot block, so it
|
||||
/// holds the black the reference was already showing for as long as that load would have
|
||||
/// taken. Same thing on screen; ours keeps rendering.
|
||||
/// followed unit, which runs at full black and *blocks* (no loading cover is raised anywhere on
|
||||
/// this path: its raise, `0x406800`, has no caller there). benilla streams asynchronously and
|
||||
/// cannot block, so it holds the black the reference was already showing for as long as that
|
||||
/// load would have taken. Same thing on screen; ours keeps rendering.
|
||||
///
|
||||
/// The value is the seconds of black left to spend waiting. **Black is never held forever:**
|
||||
/// if the world does not come back inside the budget the fade comes in anyway, and the loading
|
||||
@@ -321,8 +319,7 @@ fn drive_letterbox(
|
||||
// `UiHidden` goes back to meaning only what ALT-Z means.
|
||||
|
||||
// **The hardware cursor goes with it** — `0x58b590(0)` at StartCinematic, `(1)` at
|
||||
// EndCinematic and at the leave-world teardown (wow-re `ui/scratch/cinematic-camera-law.md`
|
||||
// §3.4, the complete 10-site census of the cinematic state cell). Nothing else in benilla
|
||||
// EndCinematic and at the leave-world teardown. Nothing else in benilla
|
||||
// would: this flag is otherwise written only by the mouse-look session in `player::camera`,
|
||||
// and `control` skips that whole branch while the view is detached, so the pointer the player
|
||||
// arrived at character-select with simply sat on top of the fly-by.
|
||||
@@ -541,8 +538,8 @@ const SETTLE_BUDGET: f32 = 8.0;
|
||||
///
|
||||
/// **The FOV is not touched at all**, and that is the correction decision 1711 landed. A fly-by is
|
||||
/// rendered through the world camera's own optics, re-stamped every frame — the M2 camera record's
|
||||
/// `fov` is written at model load and read by nothing on this path (wow-re
|
||||
/// `ui/scratch/cinematic-camera-law.md`, a 24-site census; its one raw reader is reachable only
|
||||
/// `fov` is written at model load (`0x70f336`) and read by nothing on this path (its one raw
|
||||
/// reader, `0x7ac640`, is reachable only
|
||||
/// from the portrait and `<Model>` frames). Feeding the authored 45 degrees through the reference's
|
||||
/// own `theta_v = F / sqrt(aspect^2 + 1)` builder — which is otherwise right, and angle-space
|
||||
/// division really is what `0x5c3cc0` does — rendered fifteen of the sixteen shipped shots at
|
||||
@@ -965,11 +962,11 @@ mod ack_ledger {
|
||||
|
||||
/// **The end boundary holds the black until the world is back around the body.**
|
||||
///
|
||||
/// The reference's EndCinematic does a *blocking* terrain load at full black (`0x48f080`, law
|
||||
/// §8.2) and raises no loading cover anywhere on the cinematic path (§8.4). benilla streams
|
||||
/// asynchronously, so "block" is not available — but "keep showing the black the fade already put
|
||||
/// up" is, and it produces the same thing on screen. Without it the fly-by ends into a loading
|
||||
/// cover for half a second, measured, every time.
|
||||
/// The reference's EndCinematic does a *blocking* terrain load at full black (`0x48f080`) and
|
||||
/// raises no loading cover anywhere on the cinematic path (its raise, `0x406800`, has no caller
|
||||
/// there). benilla streams asynchronously, so "block" is not available — but "keep showing the
|
||||
/// black the fade already put up" is, and it produces the same thing on screen. Without it the
|
||||
/// fly-by ends into a loading cover for half a second, measured, every time.
|
||||
#[cfg(test)]
|
||||
mod settling_under_black {
|
||||
use super::*;
|
||||
|
||||
+20
-20
@@ -1,9 +1,9 @@
|
||||
//! The combat-log **chat** arms (B297) — the second consumer of the packets
|
||||
//! [`super::combat_log`] and [`super::combat`] already read for the floating numbers.
|
||||
//! The combat log's **chat line** leg (B297) — one of the two consumers of every combat packet;
|
||||
//! the floating number is [`super::text`]'s, and [`super`]'s handlers call this leg first.
|
||||
//!
|
||||
//! The split is the reference's own. `0x629b60` is the display dispatcher: one classification of
|
||||
//! both endpoints, then a branch per outcome into a formatter that emits *text*. The floating
|
||||
//! number comes off a different path entirely (`0x625010`, the world-anchored spawn), gated by
|
||||
//! number comes off a different path entirely (`0x629d30`, which fires `COMBAT_TEXT_UPDATE`), gated by
|
||||
//! different CVars, with a different source law — which is why our two live in different files
|
||||
//! rather than in one arm with two tails. Everything here composes; the law it composes with is
|
||||
//! [`crate::ui_chat::combat`].
|
||||
@@ -25,11 +25,11 @@ use benilla_protocol::messages::{
|
||||
use crate::ui_chat::combat::{self, Family, Fills, UnitClass};
|
||||
use crate::ui_chat::ChatLog;
|
||||
|
||||
use super::super::{GuidIndex, ObjectStore, Reputations, SelfGuid};
|
||||
use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid};
|
||||
|
||||
/// The classification inputs every line needs, bundled so seven arms do not each grow six
|
||||
/// parameters. Assembled once per drain in [`super::apply_net_updates`].
|
||||
pub(super) struct ChatCtx<'a> {
|
||||
/// The classification inputs every line needs, bundled so seven legs do not each grow six
|
||||
/// parameters. Assembled per packet by [`super`]'s handlers from [`super::Log`].
|
||||
pub(crate) struct ChatCtx<'a> {
|
||||
pub self_guid: &'a SelfGuid,
|
||||
pub group: Option<&'a crate::ui_party::GroupState>,
|
||||
pub index: &'a GuidIndex,
|
||||
@@ -69,11 +69,11 @@ impl ChatCtx<'_> {
|
||||
}
|
||||
|
||||
/// A spell's display name, or `None` when the reference would emit **no line at all** for this
|
||||
/// spell (§5.5 of the §5 verdict). Two gates, both of which 1571 was missing:
|
||||
/// spell (`0x62cd80`, like every spell formatter). Two gates, both of which 1571 was missing:
|
||||
///
|
||||
/// - **`Attributes & 0x180`** — `SPELL_ATTR_DO_NOT_DISPLAY | SPELL_ATTR_DO_NOT_LOG` (the mask
|
||||
/// and the `SpellRec+0x18` offset are VERIFIED; the two enum names are wow-re's INFERRED
|
||||
/// corroboration from vmangos). This is what keeps the invisible book-keeping spells every
|
||||
/// and the `SpellRec+0x18` offset are the binary's; the enum names are inferred from vmangos
|
||||
/// `SpellDefines.h:799-800`). This is what keeps the invisible book-keeping spells every
|
||||
/// server casts constantly — proc triggers, aura tickers — out of the log entirely.
|
||||
/// - **An empty localized name.** The reference gates on the name it is about to print, so a
|
||||
/// row with no text in this locale produces silence rather than a sentence with a hole in it.
|
||||
@@ -93,8 +93,8 @@ impl ChatCtx<'_> {
|
||||
}
|
||||
|
||||
/// `0x6ea280 == 2` — **"this spell targets enemies"**, the predicate the chat-type stubs
|
||||
/// `0x627d30`/`0x627d60` run to choose between a family's `…_DAMAGE` and `…_BUFF` rows
|
||||
/// (§2.3). It reads the row, never the endpoints, which is why it lives beside
|
||||
/// `0x627d30`/`0x627d60` run to choose between a family's `…_DAMAGE` and `…_BUFF` rows.
|
||||
/// It reads the row, never the endpoints, which is why it lives beside
|
||||
/// [`Self::spell_name`] and not beside the classifier.
|
||||
///
|
||||
/// A row the catalog cannot answer reads as **harmful**, which is not a claim about the
|
||||
@@ -149,7 +149,7 @@ pub(super) fn attacker_state(
|
||||
amount: i64::from(s.damage),
|
||||
// **Only the landed-hit family grows a trailer**, and it is the only family in the whole
|
||||
// block that can show GLANCING/CRUSHING/BLOCK: `0x628410`'s four call sites, and this is
|
||||
// the one that passes a real `blocked` and a real `HitInfo` (§4.2). A dodge or a full
|
||||
// the one that passes a real `blocked` and a real `HitInfo` (`0x629ee7`). A dodge or a full
|
||||
// absorb prints no amount at all — the reference does not append to those lines.
|
||||
trailers: landed.then_some(combat::Trailers {
|
||||
absorbed: s.absorb,
|
||||
@@ -260,7 +260,7 @@ pub(super) fn spell_damage_log(
|
||||
return;
|
||||
};
|
||||
// Nothing through: the reference words it as the reason rather than as a zero, and the order
|
||||
// of the three tests is its own — absorb, then BLOCK, then resist (§4.3).
|
||||
// of the three tests is its own — absorb, then BLOCK, then resist (`0x62cd80`).
|
||||
let family = if s.damage == 0 && s.absorb > 0 {
|
||||
combat::SPELLLOGABSORB
|
||||
} else if s.damage == 0 && s.blocked > 0 {
|
||||
@@ -325,8 +325,8 @@ pub(super) fn spell_log_miss(
|
||||
// which routes `0x62bc5a` to `0x62c140` — the damage-shield two-way selector — instead of the
|
||||
// usual eight-row spell matrix. Its five other callers pass 0 and take the normal path.
|
||||
//
|
||||
// Whether that is deliberate or a 1.12 bug is **not derivable from the binary**, and wow-re
|
||||
// says so rather than guessing. We reproduce the behaviour, because an addon filtering on
|
||||
// Whether that is deliberate or a 1.12 bug is **not derivable from the binary**. We reproduce
|
||||
// the behaviour, because an addon filtering on
|
||||
// chat type has to see what the reference shows; the discriminator, if anyone wants it, is a
|
||||
// live cast that misses and a look at which ChatFrame filter catches the line. 1571 sent these
|
||||
// through `spell_kind` and had them land in `SPELL_*_DAMAGE`.
|
||||
@@ -1214,7 +1214,7 @@ fn power_drain_line(
|
||||
let family = leech_family(multiplier);
|
||||
// Arm 6. Both stubs ask the SPELL, not the family: `0x627d30` is
|
||||
// `call 0x6ea280; cmp eax,2; je -> 0x626be0 (damage) else 0x627820 (buff)` and `0x627d60` is
|
||||
// its periodic twin over `0x627d80`/`0x6274a0` (§2.3 of wow-re's combat-log chat law). So a
|
||||
// its periodic twin over `0x627d80`/`0x6274a0`. So a
|
||||
// helpful drain files under `…_BUFF` and a harmful one under `…_DAMAGE`, which is a property
|
||||
// of the row in `Spell.dbc` and not something either call site may hardcode — both of ours
|
||||
// did, in opposite directions.
|
||||
@@ -1355,7 +1355,7 @@ pub(super) fn environmental_damage_log(
|
||||
///
|
||||
/// The wire's `reputationListId` is `Faction.dbc`'s `rep_index`, not a faction id; the name comes
|
||||
/// off the row that carries that index.
|
||||
pub(super) fn faction_standing(
|
||||
pub(crate) fn faction_standing(
|
||||
deltas: &[(u32, i32)],
|
||||
reputations: &Reputations,
|
||||
factions: Option<&crate::target::ring::Factions>,
|
||||
@@ -1433,7 +1433,7 @@ fn queue(
|
||||
}
|
||||
|
||||
/// [`queue`] for a family whose `Named` slot still has to be looked up — an item entry or a unit
|
||||
/// guid rides along and the drain resolves it, holding the line until it lands (§5.7).
|
||||
/// guid rides along and the drain resolves it, holding the line until it lands (`0x6294b0`).
|
||||
fn queue_named(
|
||||
log: &mut ChatLog,
|
||||
ctx: &ChatCtx,
|
||||
@@ -1466,7 +1466,7 @@ fn queue_reported(
|
||||
fills: Fills,
|
||||
named: combat::Named,
|
||||
) -> bool {
|
||||
// **The gate is an OR over the two endpoints, not an AND** — the §5 verdict's own wording:
|
||||
// **The gate is an OR over the two endpoints, not an AND** — `0x626630`'s own test:
|
||||
// `dist²(player, src) < range(srcClass)²` **OR** `dist²(player, tgt) < range(tgtClass)²`. 1571
|
||||
// required both and therefore dropped lines the client shows: your own pet (range 100000)
|
||||
// fighting something 80 yards away is logged by the reference and was silent here.
|
||||
@@ -0,0 +1,335 @@
|
||||
//! **The combat log** — the reference's own handler cluster for the combat packets (in the net
|
||||
//! handler table since 2323, moved out of the drain's `combat_chat` and `combat_log` arm files).
|
||||
//! Every combat packet has two consumers, and the reference emits both from one handler: the
|
||||
//! **chat line** ([`chat`], the `0x629b60` display dispatcher's text) and the **floating
|
||||
//! number** with the portrait's `UNIT_COMBAT` flash ([`text`], `0x629d30` →
|
||||
//! `COMBAT_TEXT_UPDATE`; `0x625010` is the melee blood spurt, not this). They are gated by different CVars and classify by different laws, which is why the
|
||||
//! legs are two files; they answer one packet, which is why the handler is one — the line first,
|
||||
//! the number second, as the drain's match ran them.
|
||||
//!
|
||||
//! Two packets have a third consumer: the completed swing and the environmental-damage log also
|
||||
//! drive the animation layer, whose handlers ([`crate::creature_anim::net`]) are registered after
|
||||
//! these, so a kind with two handlers runs the line, then the animation.
|
||||
|
||||
use benilla_protocol::messages::SpellOutcomeLog;
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::combat_text::{CombatTextSpawn, DamageTextGates};
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{
|
||||
GuidIndex, NetCommands, NetHandlerApp, ObjectStore, Reputations, SelfGuid, ServerSoundMessage,
|
||||
};
|
||||
use crate::ui_chat::combat::{CombatLogRanges, LogPeriodicSpells};
|
||||
use crate::ui_chat::ChatLog;
|
||||
use crate::ui_unit::{CombatTextEvent, UnitCombatFeedback};
|
||||
|
||||
pub(crate) mod chat;
|
||||
pub(crate) mod text;
|
||||
|
||||
/// What the chat line classifies against — [`chat::ChatCtx`]'s inputs, built per packet by
|
||||
/// [`Ctx::ctx`]. Read-only, so a handler can hold it beside the mutable [`Sinks`].
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct Ctx<'w> {
|
||||
self_guid: Res<'w, SelfGuid>,
|
||||
group: Res<'w, crate::ui_party::GroupState>,
|
||||
index: Res<'w, GuidIndex>,
|
||||
factions: Option<Res<'w, crate::target::ring::Factions>>,
|
||||
reputations: Res<'w, Reputations>,
|
||||
spells: Option<Res<'w, crate::ui_action::Spells>>,
|
||||
/// The eight display ranges.
|
||||
ranges: Res<'w, CombatLogRanges>,
|
||||
/// `CombatLogPeriodicSpells`.
|
||||
periodic: Res<'w, LogPeriodicSpells>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn ctx(&self) -> chat::ChatCtx<'_> {
|
||||
chat::ChatCtx {
|
||||
self_guid: &self.self_guid,
|
||||
group: Some(&self.group),
|
||||
index: &self.index,
|
||||
factions: self.factions.as_deref(),
|
||||
reputations: &self.reputations,
|
||||
spells: self.spells.as_deref(),
|
||||
ranges: &self.ranges,
|
||||
periodic: self.periodic.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the two legs read from the world and write to: the object stores and poses both legs
|
||||
/// resolve endpoints through, the chat log, the number's gates and its three sinks.
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct Sinks<'w, 's> {
|
||||
/// `CombatDamage` + the two `Pet*` sub-gates.
|
||||
damage_text: Res<'w, DamageTextGates>,
|
||||
stores: Query<'w, 's, &'static mut ObjectStore>,
|
||||
poses: Query<'w, 's, &'static mut Transform>,
|
||||
log: ResMut<'w, ChatLog>,
|
||||
names: Res<'w, NameCache>,
|
||||
net: Res<'w, NetCommands>,
|
||||
text: MessageWriter<'w, CombatTextSpawn>,
|
||||
feedback: MessageWriter<'w, UnitCombatFeedback>,
|
||||
center: MessageWriter<'w, CombatTextEvent>,
|
||||
}
|
||||
|
||||
/// The combat log's plugin — registration only; the two legs' state is the chat window's and the
|
||||
/// combat text's. Added ahead of [`crate::creature_anim::CreatureAnimPlugin`] so the two kinds
|
||||
/// both answer run the line before the animation.
|
||||
pub(crate) struct CombatLogPlugin;
|
||||
|
||||
impl Plugin for CombatLogPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::AttackerState, on_attacker_state)
|
||||
.net_handler(K::SpellDamageLog, on_spell_damage_log)
|
||||
.net_handler(K::PeriodicAuraLog, on_periodic_aura_log)
|
||||
.net_handler(K::SpellHealLog, on_spell_heal_log)
|
||||
.net_handler(K::SpellEnergizeLog, on_spell_energize_log)
|
||||
.net_handler(K::DamageShield, on_damage_shield)
|
||||
.net_handler(K::SpellLogMiss, on_spell_log_miss)
|
||||
.net_handler(K::PartyKillLog, on_party_kill_log)
|
||||
.net_handler(K::SpellInstaKillLog, on_spell_insta_kill_log)
|
||||
.net_handler(K::ProcResist, on_spell_outcome_log)
|
||||
.net_handler(K::SpellOrDamageImmune, on_spell_outcome_log)
|
||||
.net_handler(K::SpellDispelLog, on_spell_dispel_log)
|
||||
.net_handler(K::DispelFailed, on_dispel_failed)
|
||||
.net_handler(K::EnchantmentLog, on_enchantment_log)
|
||||
.net_handler(K::SpellLogExecute, on_spell_log_execute)
|
||||
.net_handler(K::EnvironmentalDamageLog, on_environmental_damage_log)
|
||||
.net_handler(K::XpGain, on_xp_gain)
|
||||
.net_handler(K::ExplorationXp, on_exploration_xp)
|
||||
.net_handler(K::LevelUp, on_level_up)
|
||||
.net_handler(K::PvpCredit, on_pvp_credit);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_attacker_state(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::AttackerState(s) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::attacker_state(s, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_spell_damage_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::SpellDamageLog(s) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::spell_damage_log(s, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
text::spell_damage_log(
|
||||
s,
|
||||
&c.index,
|
||||
&c.self_guid,
|
||||
&l.stores,
|
||||
c.spells.as_deref(),
|
||||
*l.damage_text,
|
||||
&mut l.text,
|
||||
&mut l.feedback,
|
||||
&mut l.center,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **`CombatLogPeriodicSpells` gates the WHOLE packet body, and this handler is where that is
|
||||
/// expressible.** The reference's read site `0x626dee` is the first thing the handler `0x626dd0`
|
||||
/// does, and a zero jumps to the bare epilogue `0x6271b4`: no chat line, no floating tick number,
|
||||
/// no periodic miss word. Gating inside either leg would model it as two filters; it is one gate
|
||||
/// over both.
|
||||
fn on_periodic_aura_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::PeriodicAuraLog(s) = ev {
|
||||
if !c.periodic.0 {
|
||||
return;
|
||||
}
|
||||
let ctx = c.ctx();
|
||||
chat::periodic_aura_log(&s, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
text::periodic_aura_log(
|
||||
s,
|
||||
&c.index,
|
||||
&c.self_guid,
|
||||
&l.stores,
|
||||
c.spells.as_deref(),
|
||||
*l.damage_text,
|
||||
&mut l.text,
|
||||
&mut l.feedback,
|
||||
&mut l.center,
|
||||
&l.names,
|
||||
&l.net,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_spell_heal_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::SpellHealLog(s) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::spell_heal_log(s, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
text::spell_heal_log(
|
||||
s,
|
||||
&c.index,
|
||||
&c.self_guid,
|
||||
&mut l.feedback,
|
||||
&mut l.center,
|
||||
&l.names,
|
||||
&l.net,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_spell_energize_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::SpellEnergizeLog(s) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::spell_energize_log(s, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
text::spell_energize_log(s, &c.self_guid, &mut l.center);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_damage_shield(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::DamageShield(s) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::damage_shield(s, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
text::damage_shield(
|
||||
s,
|
||||
&c.index,
|
||||
&c.self_guid,
|
||||
&l.stores,
|
||||
*l.damage_text,
|
||||
&mut l.text,
|
||||
&mut l.feedback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_spell_log_miss(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::SpellLogMiss(s) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::spell_log_miss(&s, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
text::spell_log_miss(
|
||||
s,
|
||||
&c.index,
|
||||
&c.self_guid,
|
||||
&l.stores,
|
||||
c.spells.as_deref(),
|
||||
*l.damage_text,
|
||||
&mut l.text,
|
||||
&mut l.feedback,
|
||||
&mut l.center,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── the combat log's completeness block (1703): every one of these is chat-only — they carry
|
||||
// no damage number, so unlike their neighbours above they have no floating-text leg to call ──
|
||||
|
||||
fn on_party_kill_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::PartyKillLog(k) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::party_kill_log(k, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_spell_insta_kill_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::SpellInstaKillLog(k) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::spell_insta_kill_log(k, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_spell_outcome_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
let (o, immune): (SpellOutcomeLog, bool) = match ev {
|
||||
SessionEvent::ProcResist(o) => (o, false),
|
||||
SessionEvent::SpellOrDamageImmune(o) => (o, true),
|
||||
_ => return,
|
||||
};
|
||||
let ctx = c.ctx();
|
||||
chat::spell_outcome_log(o, immune, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
}
|
||||
|
||||
fn on_spell_dispel_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::SpellDispelLog(d) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::spell_dispel_log(&d, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_dispel_failed(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::DispelFailed(d) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::dispel_failed(&d, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enchantment_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::EnchantmentLog(e) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::enchantment_log(e, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_spell_log_execute(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::SpellLogExecute(x) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::spell_log_execute(&x, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_environmental_damage_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::EnvironmentalDamageLog(e) = ev {
|
||||
let ctx = c.ctx();
|
||||
chat::environmental_damage_log(e, &ctx, &l.stores, &l.poses, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_xp_gain(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
if let SessionEvent::XpGain(x) = ev {
|
||||
text::xp_gain(x, &c.index, &c.self_guid, &mut l.text, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_exploration_xp(
|
||||
In(ev): In<SessionEvent>,
|
||||
c: Ctx,
|
||||
mut l: Sinks,
|
||||
area_table: Option<Res<crate::area::AreaTableRes>>,
|
||||
exploration_sounds: Option<Res<crate::sound::ExplorationSounds>>,
|
||||
mut server_sounds: MessageWriter<ServerSoundMessage>,
|
||||
) {
|
||||
if let SessionEvent::ExplorationXp(x) = ev {
|
||||
text::exploration_xp(
|
||||
x,
|
||||
area_table.as_deref(),
|
||||
exploration_sounds.as_deref(),
|
||||
&c.index,
|
||||
&c.self_guid,
|
||||
&l.stores,
|
||||
&mut server_sounds,
|
||||
&mut l.log,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_level_up(In(ev): In<SessionEvent>, mut l: Sinks) {
|
||||
if let SessionEvent::LevelUp(lv) = ev {
|
||||
text::level_up(lv, &mut l.log);
|
||||
}
|
||||
}
|
||||
|
||||
/// An honor award (decision 1512 — the arc's other inbound message, the inspect reply, is
|
||||
/// `ui_honor`'s own handler): the combat-log line (name-resolved, so it queues) and the floating
|
||||
/// number, which are two different surfaces of one packet and are both the reference's. A
|
||||
/// DISHONORABLE kill arrives here too, carrying NEGATIVE honor — the floating text takes it
|
||||
/// signed, because the shipped `COMBAT_TEXT_HONOR_GAINED` handler prefixes a "+" only when the
|
||||
/// number is positive and therefore already expects the other case.
|
||||
fn on_pvp_credit(In(ev): In<SessionEvent>, mut l: Sinks) {
|
||||
if let SessionEvent::PvpCredit(credit) = ev {
|
||||
l.log.push_pvp_credit(
|
||||
credit.honor,
|
||||
credit.victim_guid,
|
||||
u8::try_from(credit.victim_rank).unwrap_or(0),
|
||||
);
|
||||
l.center.write(CombatTextEvent {
|
||||
message_type: "HONOR_GAINED",
|
||||
data: Some(credit.honor.to_string()),
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
-31
@@ -1,6 +1,6 @@
|
||||
//! Combat-log SMSG arm bodies for [`super::apply_net_updates`]'s dispatch match — the floating
|
||||
//! combat-text **spawn table** (decision 0137 phase 2; wow-re `worldtext-spawn-and-law.md`). One
|
||||
//! `pub(super)` fn per arm, mirroring the client's handler → emitter structure: each classifies
|
||||
//! The combat log's **floating number** leg — the combat-text **spawn table** (decision 0137
|
||||
//! phase 2; the callers of `SubmitWorldText 0x6c7840`). One fn per packet, mirroring the client's
|
||||
//! handler → emitter structure; [`super`]'s handlers call it after the chat line: each classifies
|
||||
//! the damage **source** (the color law's `K` — self / owned-by-me / anything else SUPPRESSED),
|
||||
//! resolves the outcome **recipient**, applies Gate A (self-anchored damage text is
|
||||
//! unconditionally suppressed — outgoing damage floats over the victim, incoming never floats
|
||||
@@ -28,7 +28,7 @@ use crate::names::NameCache;
|
||||
use crate::ui_chat::ChatLog;
|
||||
use crate::ui_unit::{CombatTextEvent, UnitCombatFeedback};
|
||||
|
||||
use super::super::{GuidIndex, NetCommands, ObjectStore, SelfGuid};
|
||||
use crate::net::{GuidIndex, NetCommands, ObjectStore, SelfGuid};
|
||||
|
||||
/// Gate A + anchor resolution: the recipient's entity, unless the recipient is us (`0x607140`/
|
||||
/// `0x6128b0` compare the anchor guid to the active-player cache and return before submitting).
|
||||
@@ -43,7 +43,7 @@ fn gated_anchor(guid: u64, index: &GuidIndex, self_guid: &SelfGuid) -> Option<En
|
||||
/// against the active player — `Player` (me), `Pet` (a unit whose Summoned/CreatedBy guid is me:
|
||||
/// pet, guardian, totem), or `None` = every other class, which SUPPRESSES the emit entirely
|
||||
/// (another unit's damage is never drawn; the real emitter returns before submitting).
|
||||
pub(super) fn classify_source(
|
||||
pub(crate) fn classify_source(
|
||||
guid: u64,
|
||||
index: &GuidIndex,
|
||||
self_guid: &SelfGuid,
|
||||
@@ -73,8 +73,8 @@ pub(super) fn melee_styled(spells: Option<&crate::ui_action::Spells>, spell_id:
|
||||
|
||||
/// The spell arms' shared `UNIT_COMBAT` split: a landed amount is `WOUND` (`CRITICAL` descriptor
|
||||
/// on crit), zero damage degrades to the full-`ABSORB`/`RESIST` descriptor, else nothing (a clean
|
||||
/// spell miss arrives via `SMSG_SPELLLOGMISS` instead). PROVISIONAL string mapping pending the
|
||||
/// wow-re UNIT_COMBAT emission pin (decision 0576).
|
||||
/// spell miss arrives via `SMSG_SPELLLOGMISS` instead). PROVISIONAL string mapping until the
|
||||
/// reference's UNIT_COMBAT emission is pinned down (decision 0576).
|
||||
fn spell_feedback(
|
||||
damage: u32,
|
||||
absorb: u32,
|
||||
@@ -92,12 +92,11 @@ fn spell_feedback(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spell packet → the center text's messageType (§5-verified against the emission law, wow-re
|
||||
/// `combat-text-update-emission-law.md`): landed damage is ALWAYS `SPELL_DAMAGE` — **a spell
|
||||
/// Spell packet → the center text's messageType: landed damage is ALWAYS `SPELL_DAMAGE` — **a spell
|
||||
/// crit does NOT fire DAMAGE_CRIT** (no crit-distinct type exists for spell damage; the `0x62cd80`
|
||||
/// emitter fires 29 regardless, crit changes only the chat template) — zero damage the
|
||||
/// `SPELL_ABSORBED`/`SPELL_RESISTED` word. Spell-side partial trailers stay a named residual (the
|
||||
/// verdict pinned the melee helper-B partials; the spell emitter's partial path is unread).
|
||||
/// melee partials go through helper B `0x6268f0`; the spell emitter's partial path is unread).
|
||||
fn spell_center_text(
|
||||
damage: u32,
|
||||
absorb: u32,
|
||||
@@ -114,13 +113,13 @@ fn spell_center_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// Melee packet → the center text's messageType + args (§5-verified emission shape, wow-re
|
||||
/// `combat-text-update-emission-law.md`; fired **synchronously at packet receive** — the
|
||||
/// impact-keyframe deferral belongs to the worldtext/UNIT_COMBAT victim dispatch, NOT this Lua
|
||||
/// event). Helper-B partials CONFIRMED: a landed hit with a partial block/absorb/resist fires
|
||||
/// the word type with `(damage, partial)` — the addon renders `"25 (10 blocked)"`. The
|
||||
/// partial-vs-crit precedence when both apply is unpinned — partials win here, flagged in 0580.
|
||||
pub(super) fn melee_center_text(
|
||||
/// Melee packet → the center text's messageType + args (`0x629d30`; fired **synchronously at
|
||||
/// packet receive** — the impact-keyframe deferral belongs to the worldtext/UNIT_COMBAT victim
|
||||
/// dispatch, NOT this Lua event). Helper-B partials CONFIRMED: a landed hit with a partial
|
||||
/// block/absorb/resist fires the word type with `(damage, partial)` — the addon renders
|
||||
/// `"25 (10 blocked)"`. The partial-vs-crit precedence when both apply is unpinned — partials
|
||||
/// win here, flagged in 0580.
|
||||
pub(crate) fn melee_center_text(
|
||||
hit_info: u32,
|
||||
victim_state: u32,
|
||||
damage: u32,
|
||||
@@ -334,9 +333,8 @@ pub(super) fn periodic_aura_log(
|
||||
(action, flags, amount, school)
|
||||
}
|
||||
PeriodicTick::Heal { amount } => ("HEAL", "", amount, 0),
|
||||
// NO ENERGIZE: 5875 never emits it (the string is absent binary-wide —
|
||||
// §5-verified, wow-re `unit-combat-event-law.md`); the power gain reaches the
|
||||
// center text only.
|
||||
// NO ENERGIZE: 5875 never emits it (the string is absent binary-wide); the power
|
||||
// gain reaches the center text only.
|
||||
PeriodicTick::Energize { .. } | PeriodicTick::ManaLeech { .. } => continue,
|
||||
};
|
||||
feedback.write(UnitCombatFeedback {
|
||||
@@ -461,8 +459,7 @@ pub(super) fn spell_heal_log(
|
||||
|
||||
/// `SMSG_SPELLENERGIZELOG` → the center text's `MANA`/`RAGE`/`FOCUS`/`ENERGY` line (self-only).
|
||||
/// NO `UNIT_COMBAT` fires here: the 5875 engine has no ENERGIZE emission — the string is absent
|
||||
/// from the whole binary (§5-verified, wow-re `unit-combat-event-law.md`; the shipped
|
||||
/// CombatFeedback.lua ENERGIZE arm is dead code in 1.12).
|
||||
/// from the whole binary (the shipped CombatFeedback.lua ENERGIZE arm is dead code in 1.12).
|
||||
///
|
||||
/// The amount is the **displayed** figure: the handler `0x5e8a90` divides by
|
||||
/// `0x6e7130(powerType)` at `0x5e8af3` and hands that one number to the COMBAT_TEXT push
|
||||
@@ -605,8 +602,8 @@ pub(super) fn xp_gain(
|
||||
}
|
||||
|
||||
/// `SMSG_EXPLORATION_EXPERIENCE` → the discovery announcement (decision 0828; surfaces and
|
||||
/// sound byte-verified by the 0829 RE, wow-re `system/net/net.md`). Three legs, mirroring the
|
||||
/// real handler's case body `[0x5e41d2, 0x5e42bb]`:
|
||||
/// sound from the reference, 0829). Three legs, mirroring the real handler's case body
|
||||
/// `[0x5e41d2, 0x5e42bb]`:
|
||||
/// - the ERR_ZONE_EXPLORED toast + (xp > 0) the ERR_ZONE_EXPLORED_XP chat line, queued via
|
||||
/// [`ChatLog::push_exploration`] — the area name is the packet's `AreaTable.dbc` row id
|
||||
/// resolved through the shared catalog; a miss (no catalog, or an id the DBC doesn't know)
|
||||
@@ -623,7 +620,7 @@ pub(super) fn exploration_xp(
|
||||
index: &GuidIndex,
|
||||
self_guid: &SelfGuid,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
sound_out: &mut MessageWriter<super::super::ServerSoundMessage>,
|
||||
sound_out: &mut MessageWriter<crate::net::ServerSoundMessage>,
|
||||
chat_log: &mut ChatLog,
|
||||
) {
|
||||
let race = self_guid
|
||||
@@ -634,8 +631,8 @@ pub(super) fn exploration_xp(
|
||||
.and_then(|s| s.0.unit_race());
|
||||
let kit = race.and_then(|r| sounds.and_then(|s| s.0.kit(u32::from(r))));
|
||||
if let Some(kit) = kit {
|
||||
sound_out.write(super::super::ServerSoundMessage {
|
||||
kind: super::super::ServerSoundKind::Sound2d,
|
||||
sound_out.write(crate::net::ServerSoundMessage {
|
||||
kind: crate::net::ServerSoundKind::Sound2d,
|
||||
sound_id: kit,
|
||||
source: None,
|
||||
});
|
||||
@@ -658,10 +655,9 @@ pub(super) fn exploration_xp(
|
||||
}
|
||||
|
||||
/// `SMSG_LEVELUP_INFO` → the ding's chat lines (decision 0304). The talent-count arg is not on the
|
||||
/// wire — the client computes `(newLevel >= 10) ? 1 : 0` (byte-verified `0x5e407c`, wow-re
|
||||
/// levelup-ding.md — the 0305 fold-back), exactly this. The ding's VISUAL is deliberately absent
|
||||
/// here: it rides the UNIT_FIELD_LEVEL change-watcher (`entities` spell_fx::level_up_flash), never
|
||||
/// this packet.
|
||||
/// wire — the client computes `(newLevel >= 10) ? 1 : 0` (`0x5e407c`, the 0305 fold-back), exactly
|
||||
/// this. The ding's VISUAL is deliberately absent here: it rides the UNIT_FIELD_LEVEL
|
||||
/// change-watcher (`entities` spell_fx::level_up_flash), never this packet.
|
||||
pub(super) fn level_up(l: LevelUpInfo, chat_log: &mut ChatLog) {
|
||||
let talent_points = u32::from(l.level >= 10);
|
||||
// Park the raw tuple for `ui_unit`'s `PLAYER_LEVEL_UP`, whose nine args the reference's own
|
||||
@@ -118,8 +118,7 @@ pub(crate) const COLOR_PET_MELEE_ORANGE: u32 = 0xFFFF_8400;
|
||||
///
|
||||
/// Each has a **closed reader census** in the reference: one store and exactly two reads
|
||||
/// image-wide, one in the localized-WORD emitter `0x607140` and one in the `"%d"` NUMBER emitter
|
||||
/// `0x6128b0` (wow-re `playername/scratch/worldtext-spawn-and-law.md` §4,
|
||||
/// `combattext-color-law.md`). Both branch targets are function epilogues, so a gate that fails
|
||||
/// `0x6128b0`. Both branch targets are function epilogues, so a gate that fails
|
||||
/// **suppresses the emit entirely** — it never falls through to a default colour, which is why
|
||||
/// [`damage_color`] returns `Option` rather than a colour.
|
||||
///
|
||||
@@ -132,7 +131,7 @@ pub(crate) const COLOR_PET_MELEE_ORANGE: u32 = 0xFFFF_8400;
|
||||
/// **`PetMeleeDamage` and `PetSpellDamage` do not split on "melee vs spell" the way a player
|
||||
/// would mean it.** The selector is `B` — no spell record (a real melee swing) *or* the spell's
|
||||
/// `AttributesEx` bit 15 — so a pet spell carrying that bit is gated by `PetMeleeDamage` and
|
||||
/// coloured orange. Which spell ids carry it is on wow-re's own Open board.
|
||||
/// coloured orange. Which spell ids carry it is an open question.
|
||||
///
|
||||
/// These were `const bool`s with the comment "consts until a cvar system exists". The CVar system
|
||||
/// existed; this is the row 2077's census was holding the place for.
|
||||
@@ -267,18 +266,17 @@ fn round_px(t: f64) -> f32 {
|
||||
/// The composed size law (module doc): category value `v` → on-screen pixel height. One gx unit
|
||||
/// is the **screen diagonal** `√(W²+H²)`: the screencoord device space spans `[0,G44]×[0,G48]`
|
||||
/// with `G44 = s/√(s²+1)`, `G48 = 1/√(s²+1)` (`s = W/H` — the live globals `0x832a44/48`), so
|
||||
/// `v/G48 × H = v·√(W²+H²)`. The first reading hardcoded G48's **4:3 value** (0.6 — the
|
||||
/// resolution the difftest ran at), which under-sizes ~22% at 16:9 — the director's "damage
|
||||
/// numbers should be 1–2 sizes bigger", one root cause with the small nameplates (wow-re
|
||||
/// `nameplate-vkey.md` §8 Q4). The round is the gx px law ([`round_px`]); constant with unit
|
||||
/// distance.
|
||||
/// `v/G48 × H = v·√(W²+H²)`. The first reading hardcoded G48's **4:3 value** (0.6), which
|
||||
/// under-sizes ~22% at 16:9 — the director's "damage numbers should be 1–2 sizes bigger", one
|
||||
/// root cause with the small nameplates. The round is the gx px law ([`round_px`]); constant
|
||||
/// with unit distance.
|
||||
pub(super) fn text_px(v: f32, viewport: Vec2) -> f32 {
|
||||
round_px(f64::from(v) * f64::from(viewport.x).hypot(f64::from(viewport.y)))
|
||||
}
|
||||
|
||||
/// The shadow's offset — the verified static at `0xce8804` (`{0.002, 0.002}`, init `0x6c7c20`):
|
||||
/// a **viewport fraction**, resolved per-axis and integer-rounded at draw (module doc; wow-re
|
||||
/// `worldtext-shadow-render-law.md`, TU-A — which corrected our first `× diagonal` reading: the
|
||||
/// The shadow's offset — the static at `0xce8804` (`{0.002, 0.002}`, init `0x6c7c20`):
|
||||
/// a **viewport fraction**, resolved per-axis and integer-rounded at draw (module doc; `0x5c8710`,
|
||||
/// not our first `× diagonal` reading: the
|
||||
/// `√(W²+H²)` lives only in the unrelated Lua `GetScreenWidth/Height` path).
|
||||
const SHADOW_OFFSET_FRAC: f32 = 0.002;
|
||||
|
||||
@@ -293,9 +291,9 @@ pub(super) fn shadow_offset_px(viewport: Vec2) -> Vec2 {
|
||||
}
|
||||
|
||||
/// The anti-overlap CLAIM box (full width × height, px) — the ref's measured block under its
|
||||
/// own units quirk (wow-re `worldtext-measured-block-wh-law.md`, §5 b2d59e6e): `0x6c81a0`
|
||||
/// own units quirk: `0x6c81a0`
|
||||
/// stores the halves as SCREEN FRACTIONS — height = the raw size value ÷ G48 verbatim (single
|
||||
/// line: no line gap, and the shadow-Y term is a byte-verified dead store), width = the advance
|
||||
/// line: no line gap, and the shadow-Y term is a dead store), width = the advance
|
||||
/// sum ÷ screen width plus a `round(0.002·diag)` pen seed — and `0x6c7cc0` then spends those
|
||||
/// fractions as DDC lengths in the solver rect. A fraction in a DDC slot inflates by diag/dim
|
||||
/// (1/G48 ≈ 1.667× tall, 1/G44 ≈ 1.25× wide at 4:3): the reference's generous,
|
||||
@@ -310,8 +308,8 @@ pub(super) fn claimed_box_px(ink_w: f32, size_value: f32, viewport: Vec2) -> Vec
|
||||
)
|
||||
}
|
||||
|
||||
/// The text and RENDERED shadow alpha bytes at `elapsed_ms` — `time_alpha_fade 0x6c82e0`'s two
|
||||
/// lanes (§5-verified constants 255.0 / 127.0) composed through the store seam (module doc, the
|
||||
/// The text and RENDERED shadow alpha bytes at `elapsed_ms` — the alpha fade `0x6c82e0`'s two
|
||||
/// lanes (constants 255.0 / 127.0) composed through the store seam (module doc, the
|
||||
/// STORE law): the shadow's stored alpha is **`min(shadow lane, text alpha)`** (`SetShadowColor
|
||||
/// 0x5cd650`, font node — SetColor runs first each tick, so `mainA` is the fresh text byte).
|
||||
/// Branch order is the client's: fade-in first (below fade-in-end the ramp arm wins even when
|
||||
@@ -342,7 +340,7 @@ pub(super) fn fade_alpha(cat: &Category, elapsed_ms: f32) -> (u8, u8) {
|
||||
(text, shadow.min(text))
|
||||
}
|
||||
|
||||
/// The category's scale value at normalized life `t` (`keyframe_interp 0x6c80b0`): category 2 runs
|
||||
/// The category's scale value at normalized life `t` (`0x6c80b0`): category 2 runs
|
||||
/// the crit-pop keyframes × `valueHi`; every other row is the affine `lo + (hi − lo)·t` (constant,
|
||||
/// since `lo == hi` outside the crit row). Floor 0.001, as the client clamps.
|
||||
pub(super) fn scale_value(category: u8, t: f32) -> f32 {
|
||||
@@ -378,9 +376,9 @@ mod tests {
|
||||
|
||||
/// The composed size law (`v · diagonal` → `ScreenToPixelHeight`): the exact pixel heights
|
||||
/// at the 1024×768 reference window (diag 1280 — where the old `/0.6 × H` law coincides),
|
||||
/// the round-half-away behavior, and the ASPECT correction (the whole point of the §8 Q4
|
||||
/// re-pin: a 16:9 window sizes by ITS diagonal, not the 4:3 constant).
|
||||
/// The claimed-box anchors from wow-re `worldtext-measured-block-wh-law.md` (b2d59e6e) at
|
||||
/// the round-half-away behavior, and the ASPECT correction (the whole point: a 16:9 window
|
||||
/// sizes by ITS diagonal through the live G48 `0x832a48`, not the 4:3 constant).
|
||||
/// The claimed-box anchors (`0x6c81a0`) at
|
||||
/// 1024×768: the box the solver sees runs 1/G48 taller than the glyph render — 39.1 px for
|
||||
/// the 23 px steady number, 58.7 px for the 35 px crit settle — and 1/G44 (1.25×) wider
|
||||
/// than ink + the `round(0.002·diag)` pen seed.
|
||||
@@ -413,18 +411,18 @@ mod tests {
|
||||
assert_eq!(text_px(scale_value(0, 0.5), ref43), 23.0);
|
||||
}
|
||||
|
||||
/// The shadow offset law (`worldtext-shadow-render-law.md` TU-A): a per-axis viewport
|
||||
/// The shadow offset law (`0x5c8710`): a per-axis viewport
|
||||
/// fraction, gx-rounded — NOT the isotropic diagonal (which overstated the vertical ~2.2×
|
||||
/// at 16:9 and grew with resolution).
|
||||
#[test]
|
||||
fn shadow_offset_is_a_per_axis_viewport_fraction() {
|
||||
// The verdict's own example: 1920×1080 → {round(3.84), round(2.16)} = {4, 2}.
|
||||
// 1920×1080 → {round(3.84), round(2.16)} = {4, 2}.
|
||||
assert_eq!(
|
||||
shadow_offset_px(Vec2::new(1920.0, 1080.0)),
|
||||
Vec2::new(4.0, 2.0)
|
||||
);
|
||||
// The 4:3 reference window: {round(2.048), round(1.536)} = {2, 2} — the old note's
|
||||
// "~2.6 px" diagonal read never rendered.
|
||||
// The 4:3 reference window: {round(2.048), round(1.536)} = {2, 2} — the "~2.6 px"
|
||||
// diagonal read never rendered.
|
||||
assert_eq!(
|
||||
shadow_offset_px(Vec2::new(1024.0, 768.0)),
|
||||
Vec2::new(2.0, 2.0)
|
||||
@@ -442,9 +440,9 @@ mod tests {
|
||||
assert_eq!(CATEGORIES[5].color, 0xFFE0_CA0A);
|
||||
}
|
||||
|
||||
/// `time_alpha_fade 0x6c82e0` at the pinned points of row 0 (in 150 / out 760 / dur 1500),
|
||||
/// The alpha fade `0x6c82e0` at the pinned points of row 0 (in 150 / out 760 / dur 1500),
|
||||
/// composed through the store seam (rendered values): fade-in ramps 255·t / 127·t over the
|
||||
/// DURATION (so the fade-in boundary is a step to 255, the byte-verified pop-in), the
|
||||
/// DURATION (so the fade-in boundary is a step to 255, the pop-in), the
|
||||
/// plateau is the unconditional `(0xFF, 0x7F)`, the fade-out drops the text
|
||||
/// `255 − clamp(255·u)` — and the SHADOW's raw `[128, 255]` inversion is min-capped to the
|
||||
/// text alpha by the `0x5cd650` store, so it steps to ~0xFF at fade-out start and then
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! **Floating combat text** (decision 0137 phase 2) — the client's WORLDTEXTSTRING engine
|
||||
//! (wow-re `playername/scratch/worldtext-spawn-and-law.md`, §5-verified 2026-07-05).
|
||||
//! **Floating combat text** (decision 0137 phase 2) — the client's WORLDTEXTSTRING engine.
|
||||
//!
|
||||
//! The law, transcribed: a combat-log SMSG's handler picks a **category 0–5** and submits a string
|
||||
//! over the damage/outcome **recipient**; the text is **world-anchored** — the unit's OVERHEAD
|
||||
@@ -14,10 +13,9 @@
|
||||
//! head; the XP emitter (category 4) is self-anchored by design and skips the gate. Heals and
|
||||
//! energize NEVER float in 5875 (chat log only; there is no `CombatHealing` cvar).
|
||||
//!
|
||||
//! **The COLOR law** (wow-re `playername/scratch/combattext-color-law.md`, §5-verified
|
||||
//! 2026-07-06 — the director's "melee vs spell differ" CONFIRMED): the emitters key two bits —
|
||||
//! `B` (record NULL ⇒ melee, or **AttributesEx3 bit-15** — `SpellRec+0x24`'s word; the note's
|
||||
//! "+0x25 sign" byte) and `K` (the `0x5efea0` **source-ownership** class: self / owned-by-me /
|
||||
//! **The COLOR law** (the director's "melee vs spell differ" CONFIRMED): the emitters key two bits
|
||||
//! — `B` (record NULL ⇒ melee, or **AttributesEx3 bit-15** — `SpellRec+0x24`'s word, tested as the
|
||||
//! sign of byte `+0x25`) and `K` (the `0x5efea0` **source-ownership** class: self / owned-by-me /
|
||||
//! other). Master gate: the CombatDamage cvar. Self melee → **WHITE** (NULL override ⇒ the row
|
||||
//! default); self spell/periodic → **GOLD** `0xFFFFDE00`; pet melee → **ORANGE** `0xFFFF8400`
|
||||
//! (PetMeleeDamage cvar); pet spell → GOLD (PetSpellDamage cvar); **any OTHER source is
|
||||
@@ -43,14 +41,14 @@
|
||||
//! reads "Resist" when the ball lands, a missed Sinister Strike at packet receive.
|
||||
//! [`missile_miss_text`] is that arrival half, fed by [`crate::entities::MissileMiss`].
|
||||
//!
|
||||
//! **The render geometry** (wow-re `worldtext-geometry-law.md`, §5-verified, 9af65294 — zero free
|
||||
//! **The render geometry** (zero free
|
||||
//! parameters; the anchor-seat half corrected here, see the seating comment in
|
||||
//! [`float_combat_text`]): the rise is added to **world z before projection**; the block is
|
||||
//! **h-centered with its bottom at** the projected point (rising above it), clamped on-screen —
|
||||
//! but only once the projector has ACCEPTED the point at all: a number whose anchor lands behind
|
||||
//! the camera or outside the viewport is **destroyed and its slot released**
|
||||
//! (`6c7d9d` → `6c6dda` → `0x6c86a0`; wow-re `object-layer/scratch/nameplate-offscreen-cull.md`,
|
||||
//! §5-VERIFIED 2026-08-15), never clamped to a border and never claiming a bucket-1 rect. Ours
|
||||
//! (`6c7d9d` → `6c6dda` → `0x6c86a0`),
|
||||
//! never clamped to a border and never claiming a bucket-1 rect. Ours
|
||||
//! clamped, which put another unit's numbers on the screen edge — the worldtext half of 1341's
|
||||
//! phantom plates, and the half that record's §7 wrongly called byte-attested;
|
||||
//! the on-screen size is
|
||||
@@ -58,8 +56,8 @@
|
||||
//! and composes as `px = round_half_away(v × √(W²+H²))` — `v` the category's interpolated scale
|
||||
//! value, the diagonal the gx screencoord unit (`0x832a44/48` hold the live aspect basis; the
|
||||
//! first reading hardcoded its 4:3 value 0.6 — see [`text_px`]), the round being the gx
|
||||
//! `ScreenToPixelHeight` law (`0x5c6fa0`, wow-re `crates/font/src/screen_pixel.rs`, bit-exact
|
||||
//! difftested). At 1024×768 (diag 1280): normal number 23 px, crit settles 35 px, pop peaks ~70 px.
|
||||
//! `ScreenToPixelHeight` law (`0x5c6fa0`). At 1024×768 (diag 1280): normal number 23 px, crit
|
||||
//! settles 35 px, pop peaks ~70 px.
|
||||
//! The font is **whatever the Lua global `DAMAGE_TEXT_FONT` holds** when `0x6c8470` reads it —
|
||||
//! see [`DamageTextFont`] for the binding law (a plain `lua_gettable`, evaluated once, at the tail
|
||||
//! of the world-entry UI load, i.e. after every addon's `ADDON_LOADED`). `Fonts\FRIZQT__.TTF` is
|
||||
@@ -68,9 +66,8 @@
|
||||
//! NULL handle and the combat text simply does not draw. Created flags 0 (`6c8493 xor edx,edx` —
|
||||
//! not `6c8498`, which is `mov ecx,esi`): no outline.
|
||||
//!
|
||||
//! **The ALPHA + SHADOW law** (`time_alpha_fade 0x6c82e0` — wow-re
|
||||
//! `playername/scratch/worldtext-alpha-shadow-law.md`, §5 pair + the emulated bit-exact difftest,
|
||||
//! 2026-07-12; the director's "XP text less visible than ref"): the per-tick fade REPLACES the
|
||||
//! **The ALPHA + SHADOW law** (the alpha fade `0x6c82e0`; the director's "XP text less visible
|
||||
//! than ref"): the per-tick fade REPLACES the
|
||||
//! live color's alpha byte (`mov [obj+0x23], bl` — the ARGB high byte), it never multiplies. The
|
||||
//! plateau between fade-in end and fade-out start is an **unconditional `(0xFF, 0x7F)`** — so the
|
||||
//! config table's packed alpha (row 4's `0x80`) is dead data, overwritten before it ever renders:
|
||||
@@ -85,9 +82,9 @@
|
||||
//! black, `(al<<24)|0` → `SetShadowColor 0x5c27a0` with the static offset `0xce8804 =
|
||||
//! {0.002, 0.002}` (init `0x6c7c20`), every category, unconditionally.
|
||||
//!
|
||||
//! **The STORE seam caps the shadow** (font node `outline-bake-tint.md` §5, byte-verified):
|
||||
//! **The STORE seam caps the shadow**:
|
||||
//! `SetShadowColor`'s persistent store `0x5cd650` writes the shadow colour's alpha byte as
|
||||
//! **`min(shadowA, mainA)`** — and `time_alpha_fade` calls SetColor (text) *then* SetShadowColor
|
||||
//! **`min(shadowA, mainA)`** — and the alpha fade calls SetColor (text) *then* SetShadowColor
|
||||
//! every tick, so the RENDERED shadow alpha is `min(shadow lane, text alpha)`. The fade-out
|
||||
//! lane's byte-true `[128, 255]` inversion is dead past the text's own value: the rendered
|
||||
//! shadow steps up to ~0xFF at fade-out start, then tracks the text alpha down to **0** — it
|
||||
@@ -96,13 +93,11 @@
|
||||
//! fraction**, converted per-axis at draw (`0x5c8710` → `ScreenToPixelWidth 0x5c7010` ×W /
|
||||
//! `ScreenToPixelHeight 0x5c6fa0` ×H, each `+0.5`-truncate rounded): `offset_px =
|
||||
//! {round(0.002·W), round(0.002·H)}`, drawn **down-right** (the Y-down ortho), shadow pass
|
||||
//! before main — the whole as-rendered law is wow-re
|
||||
//! `playername/scratch/worldtext-shadow-render-law.md` (§5 three pairs, 2026-07-13).
|
||||
//! before main.
|
||||
//!
|
||||
//! **The anti-overlap push IS real** — decision 0367, superseding 0363's "none exists" (that
|
||||
//! verdict was true of the worldtext code and wrong about the system: the push lives in the
|
||||
//! shared `UIUtil\SmartScreenRect` draw-time solver, wow-re
|
||||
//! `ui/scratch/smartscreenrect-solver-law.md`, §5 2026-07-13). Every frame each live string
|
||||
//! shared `UIUtil\SmartScreenRect` draw-time solver). Every frame each live string
|
||||
//! submits its desired rect — the clamped projected center ± its **crit-pop-scaled** measured
|
||||
//! half-extents — to claim **bucket 1** ([`crate::smart_rect`]), gets relocated off the strings
|
||||
//! already seated this frame, and draws from the returned rect (`0x6c7cc0` → `0x509520`,
|
||||
@@ -187,7 +182,7 @@ pub(crate) struct WorldTexts(Vec<WorldText>);
|
||||
/// pointer is a borrowed `const char*` into the Lua TString, consumed before the call returns.
|
||||
/// `0x6c847c` is the **only** instruction in the image that reads this global.
|
||||
///
|
||||
/// **When** is the whole finding. `0x6c8470` is not CRT init, as three wow-re notes had it — it
|
||||
/// **When** is the whole finding. `0x6c8470` is not CRT init — it
|
||||
/// runs from `0x401570 + 0x1620`, *after* the UI load `0x401602`, so after FrameXML has run
|
||||
/// `Fonts.xml` and after every non-LoadOnDemand addon's `ADDON_LOADED`. That is exactly where
|
||||
/// MikScrollingBattleText (`MikScrollingBattleText.lua:255`) and pfUI (`pfUI.lua:179`) assign it,
|
||||
@@ -341,8 +336,7 @@ pub(crate) fn float_combat_text(
|
||||
order.sort_by_key(|&i| (texts.0[i].anchor, std::cmp::Reverse(texts.0[i].slot)));
|
||||
// A number whose anchor fails the projector's accept verdict is **destroyed, and its slot
|
||||
// released** (`6c7d9d` → `6c6dda` → `0x6c86a0`) — not skipped for a frame, and never clamped
|
||||
// to a border (wow-re `object-layer/scratch/nameplate-offscreen-cull.md`, §5-VERIFIED
|
||||
// 2026-08-15). Collected here and reaped after the walk, since the walk holds the list.
|
||||
// to a border. Collected here and reaped after the walk, since the walk holds the list.
|
||||
let mut culled: Vec<usize> = Vec::new();
|
||||
for i in order {
|
||||
let t = &texts.0[i];
|
||||
@@ -402,7 +396,7 @@ pub(crate) fn float_combat_text(
|
||||
// half-extents, run it through claim bucket 1 — normalize → solve → clamp → claim
|
||||
// ([`crate::smart_rect`]) — and place the string at the SOLVED rect's center. The ink
|
||||
// then sits h-centered with its BOTTOM at that point: the string's own justify pair at
|
||||
// creation (`6c8254 push 0x2` in `string_measure_layout`'s `GxuFontCreateString` —
|
||||
// creation (`6c8254 push 0x2` in the measure layout `0x6c81a0`'s `GxuFontCreateString` —
|
||||
// vertical justify 2 = BOTTOM through `ComputeAnchor 0x5cdf70`'s `anchor.y = h +
|
||||
// anchor.y` arm; horizontal 0 = left, the caller pre-subtracts hw → net h-centered), so
|
||||
// the ink rises above the seat point while the claimed rect brackets it — the ref's
|
||||
@@ -411,7 +405,7 @@ pub(crate) fn float_combat_text(
|
||||
// The claimed box is the ref's MEASURED BLOCK under its own units quirk — the box
|
||||
// the solver sees runs 1/G48 (~1.667×) taller and 1/G44 (~1.25×) wider than the
|
||||
// rendered glyphs, the reference's generous size-proportional padding
|
||||
// ([`claimed_box_px`], wow-re `worldtext-measured-block-wh-law.md`).
|
||||
// ([`claimed_box_px`], `0x6c81a0`).
|
||||
let claim = claimed_box_px(b.width(), size_value, viewport);
|
||||
let (hw, hh) = (claim.x * 0.5, claim.y * 0.5);
|
||||
let cx = screen
|
||||
@@ -663,8 +657,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// **A travelling spell's outcome word is DEFERRED, and it is GOLD** (decision 2229) — the
|
||||
/// arrival half of the miss text, and the case the whole round came from: a resisted Fireball
|
||||
/// reads "Resist" in spell gold when the ball lands, not white at GO.
|
||||
/// arrival half of the miss text: a resisted Fireball reads "Resist" in spell gold when the
|
||||
/// ball lands, not white at GO.
|
||||
///
|
||||
/// Four legs, each an independent branch of the law `0x607140` runs for every one of its
|
||||
/// callers:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The in-game **console command registry** (decision 2303) — the reference's `ConsoleCommand`
|
||||
//! table, host side: `ConsoleCommandRegister 0x63f9e0` over a `TSExplicitList<CONSOLECOMMAND>`,
|
||||
//! with parse / register / lookup / execute (wow-re `console/console.md`, the upper band). A
|
||||
//! with parse / register / lookup / execute (the registry runtime, `[0x63f880, 0x640c50)`). A
|
||||
//! subsystem registers its commands from its own plugin ([`ConsoleCommandApp::console_command`]),
|
||||
//! the way each of the reference's registers into the one table, and `/console <line>` reaches
|
||||
//! [`execute`] through the chat drain.
|
||||
|
||||
@@ -46,38 +46,10 @@ pub(crate) fn install(build: BuildId) {
|
||||
}));
|
||||
}
|
||||
|
||||
/// `WOW_CRASH_INJECT=<at_secs>` — one deliberate main-thread panic, mid-run: the standing test
|
||||
/// affordance for this module, in the shape of `perf::stall`'s injectors and armed beside them.
|
||||
/// The end-to-end falsifier is a run with it set: the process must die with a
|
||||
/// `crash-<unix>.txt` whose log tail ends in the `crash-inject` line below.
|
||||
pub(crate) fn arm_injector(app: &mut bevy::prelude::App) {
|
||||
use bevy::prelude::*;
|
||||
if let Some(at) = std::env::var("WOW_CRASH_INJECT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
{
|
||||
app.insert_resource(CrashInject { at });
|
||||
app.add_systems(Update, crash_inject);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(bevy::prelude::Resource)]
|
||||
struct CrashInject {
|
||||
at: f32,
|
||||
}
|
||||
|
||||
fn crash_inject(
|
||||
inject: bevy::prelude::Res<CrashInject>,
|
||||
time: bevy::prelude::Res<bevy::prelude::Time<bevy::time::Real>>,
|
||||
) {
|
||||
if time.elapsed_secs() >= inject.at {
|
||||
bevy::log::warn!(
|
||||
"crash-inject: panicking the main thread at {:.1} s",
|
||||
inject.at
|
||||
);
|
||||
panic!("crash-inject: deliberate panic (WOW_CRASH_INJECT)");
|
||||
}
|
||||
}
|
||||
// The standing test affordance — `WOW_CRASH_INJECT=<at_secs>`, one deliberate main-thread panic
|
||||
// mid-run — is `perf::crash_inject`, a dev root: this module ships to players and knows nothing
|
||||
// of the `dev` seam (run_mode's one-door rule, 1176), and an instrument's home is with the
|
||||
// instruments. Its module doc carries the cross-platform incident that moved it there.
|
||||
|
||||
/// Re-entrancy latch: a panic *inside* the hook (a poisoned lock, a failed write) must not
|
||||
/// recurse into it. Never cleared on purpose — after one report the process is on its way out,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
//!
|
||||
//! Regimes (decisions 0049 → 0083/0087):
|
||||
//! - **Gait** — the directly-cross-faded ground/swim gaits + idle: Stand / Walk / Run / Fast-run by speed,
|
||||
//! WalkBackwards when moving back, the swim ids when swimming (verified client selector, wow-5875-re
|
||||
//! RF-0057). A change cross-fades to the new clip over its `blend_time` instead of snapping.
|
||||
//! WalkBackwards when moving back, the swim ids when swimming (the client's selector `0x5fd8b0`).
|
||||
//! A change cross-fades to the new clip over its `blend_time` instead of snapping.
|
||||
//! - **Special** — the stand-state poses (sit/sleep/kneel: down → loop → up) as one-shot-bracketed
|
||||
//! loops, **preemptible** (decision 0055), plus the jump's enter/hang. The jump's *landing* is NOT a
|
||||
//! bracket: [`select::Mode::Land`] is a plain pick from the input at touchdown, freely overwritten the
|
||||
@@ -20,7 +20,7 @@
|
||||
//! torso-masked overlay ([`AnimClip::upper_node`]) while the base keeps driving the legs underneath —
|
||||
//! never dropped, never bracket-gated.
|
||||
//!
|
||||
//! The playback **rate** of a locomotion clip is `speed / sequence.moveSpeed` (wow-5875-re `0x5fe2f0`), so
|
||||
//! The playback **rate** of a locomotion clip is `speed / sequence.moveSpeed` (`0x5fe2f0`), so
|
||||
//! a unit moving faster than the clip's design speed cycles its legs proportionally faster (the fix for a
|
||||
//! backpedal that otherwise drags). Non-locomotion clips (idle, the jump/sit transitions — `moveSpeed 0`)
|
||||
//! play at 1×. Death (decoded health 0) overrides everything.
|
||||
@@ -42,9 +42,9 @@ use bevy::prelude::*;
|
||||
use benilla_assets::AssetSet;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
/// The pure animation-selection logic (RF-0057/0073 tables, movement/Special state, gait/swing/ready
|
||||
/// picks, playback-rate math) — kept in its own file as it carries the bulk of the unit-tested selector
|
||||
/// logic, separate from the Bevy driver systems in [`driver`].
|
||||
/// The pure animation-selection logic (the `0x5fd100`/0073 tables, movement/Special state,
|
||||
/// gait/swing/ready picks, playback-rate math) — kept in its own file as it carries the bulk of the
|
||||
/// unit-tested selector logic, separate from the Bevy driver systems in [`driver`].
|
||||
pub(crate) mod select;
|
||||
pub(crate) use select::{
|
||||
ease_strafe_yaw, move_flags, strafe_body_offset, swim_body_rotation, MovementState,
|
||||
@@ -59,8 +59,8 @@ pub(crate) use twist::{wrap_pi, BodyTwist};
|
||||
/// The unit animation-LOD gate (decision 0448): park an off-frustum rig's per-bone pose
|
||||
/// evaluation — the clocks, the driver state machine, and the event tracks keep running, so
|
||||
/// off-screen combat stays audible (0075) and a re-appearing unit snaps to the absolute-clock
|
||||
/// pose. Shipped as a modernization; wow-re's 2026-08-13 election correction
|
||||
/// (`outdoor-object-pass-election.md`) made it the faithful direction instead — decision 1473
|
||||
/// pose. Shipped as a modernization, it is the faithful direction instead: the reference does not
|
||||
/// tick an off-frustum unit (`0x683dd0`'s second walk) — decision 1473
|
||||
/// records that, and what still diverges (parked rigs keep drawing; the reference keeps only
|
||||
/// `MORE_AUDIBLE` creatures audible off-screen).
|
||||
mod lod;
|
||||
@@ -86,8 +86,8 @@ pub(crate) struct Wielded {
|
||||
/// ([`select::ranged_load_anim`], the client's `0x5fd530` LUT; decision 0099 phase 5).
|
||||
pub(crate) ranged: Option<(u8, u8)>,
|
||||
/// The mainhand/offhand items' sheath *types* (1 back-2H · 2 back-staff · 3 hip · 4 shield ·
|
||||
/// 0 none) — each picks its arm's draw/stow one-shot independently (VERIFIED wow-re
|
||||
/// `sheath-anim-pick.md`: `(1 << (rec[+4] & 0x1f)) & 0x88` → HipSheath 90 for types {3, 7},
|
||||
/// 0 none) — each picks its arm's draw/stow one-shot independently (`0x611930`:
|
||||
/// `(1 << (rec[+4] & 0x1f)) & 0x88` → HipSheath 90 for types {3, 7},
|
||||
/// Sheath 89 otherwise; the slot's own byte is the *only* input).
|
||||
pub(crate) main_sheath: u8,
|
||||
pub(crate) off_sheath: u8,
|
||||
@@ -129,8 +129,7 @@ pub(crate) const UNIT_FLAG_DISARMED: u32 = 0x0020_0000;
|
||||
pub(crate) const ITEM_CLASS_WEAPON: u8 = 2;
|
||||
|
||||
/// **The disarm ladder** — which single held slot `UNIT_FLAG_DISARMED` hides, given what each
|
||||
/// melee hand holds (decision 1863; wow-re `disarm-weapon-gate-law.md` §2, byte-verified by a
|
||||
/// trio including a cold decoder-first read, both class bodies identical).
|
||||
/// melee hand holds (decision 1863; both class bodies identical).
|
||||
///
|
||||
/// The reference does not "empty a disarmed unit's hands". `GetWeapon(slot, visFlag = 0)` runs a
|
||||
/// **two-probe ladder with main-hand precedence**, and the off-hand probe's polarity is the
|
||||
@@ -198,46 +197,44 @@ impl Wielded {
|
||||
/// **It carries that GUID**, because the reference's `[+0xc48]` is the target and not a flag, and
|
||||
/// a second reader wants the unit and not just the fact: `0x6e3480`'s melee arm resolves
|
||||
/// `[caster+0xc48]` and uses THAT unit's combat reach, which is what the spell tooltip's range
|
||||
/// cell prints while you are auto-attacking (wow-re
|
||||
/// `tooltip-damage-matrix-and-container-slots.md` §D4.2b). Every animation reader still asks only
|
||||
/// cell prints while you are auto-attacking. Every animation reader still asks only
|
||||
/// `With`/`Has`, which is unchanged by the payload.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct Engaged(pub(crate) u64);
|
||||
|
||||
/// The local player has fired an auto-repeat spell (Auto Shot / wand Shoot) — the client's
|
||||
/// `[+0xd58] & 0x200`, whose **only writer binary-wide** is the local cast-send tail (`0x6e593b`,
|
||||
/// gated `AttributesEx2 & 0x20`; byte-verified, wow-re `ranged-shot-anim.md`). While it and the
|
||||
/// gated `AttributesEx2 & 0x20`). While it and the
|
||||
/// ranged **sheath** gate (`CUR == 2`) both hold, a standing unit's base idle is the ranged
|
||||
/// weapon's Load clip ([`select::ranged_load_anim`]) — the client's resolver `0x5fd460`,
|
||||
/// which gates on exactly this bit; local-player-only by construction (remote units never run
|
||||
/// the local cast-send). A REMOTE shooter has no drawn idle at all: it plays LoadBow **once** at
|
||||
/// the volley's single `SMSG_SPELL_START` through the PrecastKit ([`CastHold`]), then the fire
|
||||
/// clip per GO over its ordinary idle (wow-re `shooter-stop-law.md` §J6 claim 2). Cleared
|
||||
/// the volley's single `SMSG_SPELL_START` through the PrecastKit ([`CastHold`], `0x6e7901`), then
|
||||
/// the fire clip per GO over its ordinary idle. Cleared
|
||||
/// only by [`cancel_auto_repeat_local`]'s callers — the client's cancel `0x6ea080` (its `0x200`
|
||||
/// clear `0x6ea113`), reached from the cast-result fail of the cached spell, the button
|
||||
/// re-press toggle, melee attack-start, target death, the wand-only new-cast handoff, and
|
||||
/// `SMSG_CANCEL_AUTO_REPEAT` — which vmangos DOES send (corrected 2026-08-05; see
|
||||
/// [`crate::net::apply::spells::cancel_auto_repeat`]), so against a live server that packet, not
|
||||
/// a local death watcher, is what ends a volley whose target dies (wow-re
|
||||
/// `nocked-ammo-cancel.md`).
|
||||
/// `crate::spell::net`'s `cancel_auto_repeat`), so against a live server that packet, not
|
||||
/// a local death watcher, is what ends a volley whose target dies.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct AutoRepeatArmed;
|
||||
|
||||
/// The weapon-visual hold — the client's `[+0xd58] & 0x400`, set for **ANY caster** (self,
|
||||
/// remote players, NPCs) whose **ranged** spell visual plays (`0x60d020`, sole caller `0x6ec2cf`
|
||||
/// inside `PlaySpellVisual`; byte-verified, wow-re `ranged-sheath-exempt-autorepeat.md` §Q4).
|
||||
/// inside `PlaySpellVisual`).
|
||||
/// Cleared like the client's id-matched `0x60d040` + its unconditional sibling `0x60fc50`: a
|
||||
/// **non-ranged** visual play by the same caster (the stale-visual cleanup `0x6ec39e`), the
|
||||
/// local cancel (`0x6ea12b`), melee attack-start, and the spline-move apply (`0x6020e8`, wow-re
|
||||
/// `shooter-stop-law.md` §J1.3). Deliberately NOT cleared on sheath change or volley end — the
|
||||
/// local cancel (`0x6ea12b`), melee attack-start, and the spline-move apply (`0x6020e8`).
|
||||
/// Deliberately NOT cleared on sheath change or volley end — the
|
||||
/// client's bit persists latent.
|
||||
///
|
||||
/// **It gates nothing here — but no longer for the reason this doc used to give.** `0x400` is
|
||||
/// tested in exactly one place image-wide: `0x5fc3f0`'s Hold self-loop gates (`test ah,0x6` for
|
||||
/// HoldBow 109, `test ah,0x4` for 110/111/112). 0994 recorded that the dispatcher is never
|
||||
/// reached for a bow id (`shooter-stop-law.md` §J4.1) and concluded the bit is dead. **wow-re's
|
||||
/// §5 refuted that absence proof** (decision 1544): the dispatcher has a second, deferred fire
|
||||
/// site, so those gates DO execute — they are the Hold's own per-completion re-arm.
|
||||
/// reached for a bow id and concluded the bit is dead. **That absence proof is wrong**
|
||||
/// (decision 1544): the dispatcher has a second, deferred fire site (`0x7075af`), so those gates
|
||||
/// DO execute — they are the Hold's own per-completion re-arm.
|
||||
///
|
||||
/// We still gate nothing on it, and that is now a *modelling* choice with a stated equivalence
|
||||
/// rather than a claim about the binary. Our Hold is armed once and loops until a real recompute
|
||||
@@ -251,10 +248,9 @@ pub(crate) struct AutoRepeatArmed;
|
||||
pub(crate) struct RangedHold;
|
||||
|
||||
/// A unit's displayed **nocked ammo** — the client's `[+0xd28]`/`[+0xd2c]` ammo-model pair,
|
||||
/// written only by `CGUnit::UpdateAmmoDisplay 0x60ba30` (byte-verified, wow-re
|
||||
/// `nocked-ammo-cancel.md`): the ammo's `ItemDisplayInfo` id, rendered as a model on the unit's
|
||||
/// **body** at HandArrow (35) — the ONE bone attach, bow-only (§E2; the old Special2/Special3
|
||||
/// reading is refuted — those are model-directory selectors, §E1). Set per shot from the
|
||||
/// written only by `CGUnit::UpdateAmmoDisplay 0x60ba30`: the ammo's `ItemDisplayInfo` id, rendered
|
||||
/// as a model on the unit's **body** at HandArrow (35) — the ONE bone attach, bow-only (`0x60bb19`;
|
||||
/// `0x479f40`'s 0x18/0x19 pick a model directory, not Special2/Special3). Set per shot from the
|
||||
/// `SMSG_SPELL_START` `CAST_FLAG_AMMO` tail (any caster — the only source for a remote/NPC
|
||||
/// shooter). This is the display-id CACHE; whether the arrow is visibly in the hand is the
|
||||
/// [`NockLatch`] (the `$BWP`/`$BWR` cycle). Removed by the un-nock `0x60f530` paths: the local
|
||||
@@ -265,7 +261,7 @@ pub(crate) struct NockedAmmo {
|
||||
pub(crate) display_id: u32,
|
||||
}
|
||||
|
||||
/// The client's nock latch `[+0xd58] & 0x4000` (wow-re `nocked-ammo-cancel.md` §G1/G4): SET at
|
||||
/// The client's nock latch `[+0xd58] & 0x4000` (set `0x624b70`, cleared `0x60016c`): SET at
|
||||
/// the body clip's **`$BWP`** BowPull keyframe (LoadBow 105 carries it at ~0.6 s — the visible
|
||||
/// pull-from-quiver moment, which also (re)attaches the in-hand arrow), CLEARED at **`$BWR`**
|
||||
/// BowRelease (AttackBow 46 carries it at ~0.067 s — the arrow leaves the hand as the missile
|
||||
@@ -301,8 +297,8 @@ pub(crate) fn drive_nock_latch(
|
||||
}
|
||||
}
|
||||
|
||||
/// The one local auto-repeat cancel — benilla's `0x6ea080` (byte-verified whole, wow-re
|
||||
/// `nocked-ammo-cancel.md`): clear the live key (`[0xceac30]`), drop the Load/Hold idle gates
|
||||
/// The one local auto-repeat cancel — benilla's `0x6ea080`: clear the live key (`[0xceac30]`),
|
||||
/// drop the Load/Hold idle gates
|
||||
/// (the `0x200`/`0x400` bits — [`AutoRepeatArmed`] + [`RangedHold`]), un-nock the ammo
|
||||
/// (`0x60f530` — [`NockedAmmo`]), and ack the server (`CMSG_CANCEL_AUTO_REPEAT_SPELL`, the
|
||||
/// client's sole send site `0x6ea0c6`) — **sheath untouched**: the weapon stays drawn, the idle
|
||||
@@ -310,7 +306,7 @@ pub(crate) fn drive_nock_latch(
|
||||
/// live callers.
|
||||
pub(crate) fn cancel_auto_repeat_local(
|
||||
entity: Option<Entity>,
|
||||
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
|
||||
auto_repeat: &mut crate::spell::AutoRepeatActive,
|
||||
commands: &mut Commands,
|
||||
net: &crate::net::NetCommands,
|
||||
) {
|
||||
@@ -327,15 +323,14 @@ pub(crate) fn cancel_auto_repeat_local(
|
||||
let _ = net.0.send(crate::net::ClientCommand::CancelAutoRepeat);
|
||||
}
|
||||
|
||||
/// The one local **StopAttack** — benilla's `0x5ecac0` (wow-re §5 trio-verified,
|
||||
/// `object-layer/scratch/melee-autorepeat-exclusion.md`): player-only, a no-op unless an attack
|
||||
/// The one local **StopAttack** — benilla's `0x5ecac0`: player-only, a no-op unless an attack
|
||||
/// lock is held, then `CMSG_ATTACKSTOP` (its builder `0x624370` pushes opcode `0x142`) followed by
|
||||
/// the tail jump `0x5ecb63 → CancelQueuedCast 0x6e6f30` — which takes the **queued on-next-swing
|
||||
/// spell** down with the attack: `0x6e6f30`'s first leg tests the inflight id `[0xceca88]` for
|
||||
/// `Attributes & 0x404` and hands it to `CancelCast 0x6e4940(dl=1, reason 0x1c)`, whose casting arm
|
||||
/// sends `CMSG_CANCEL_CAST 0x12f` naming it and then pops the slot through `0x6e4ad0`.
|
||||
///
|
||||
/// This is the un-queue [`crate::ui_cast::QueuedMeleeSpell`] names as its real clear path, and it
|
||||
/// This is the un-queue [`crate::spell::QueuedMeleeSpell`] names as its real clear path, and it
|
||||
/// is why a Raptor Strike ring goes dark the moment Auto Shot starts (the auto-repeat arm inside
|
||||
/// the cast commit calls straight into here, `0x6e5976`, guarded by nothing but "the caster is the
|
||||
/// active player").
|
||||
@@ -346,12 +341,12 @@ pub(crate) fn cancel_auto_repeat_local(
|
||||
/// are deliberately unmodelled, neither with a benilla reader: `[+0xc50]` ("locally initiated, not
|
||||
/// server-confirmed") and `[+0xc54]` ("stop sent, awaiting the echo") — the latter's sole reader
|
||||
/// image-wide is `0x5eccda`, inside `Attack 0x5ecb70`, which no benilla cast path enters while
|
||||
/// engaged (see [`crate::ui_action::cast_send`]'s tail). The two ratio legs `0x5ecac0` runs before
|
||||
/// engaged (see [`crate::spell::cast_send`]'s tail). The two ratio legs `0x5ecac0` runs before
|
||||
/// the stop are `TriggerTutorial(0xa)` / `(0xb)` — the low-health / low-mana popups, not audio and
|
||||
/// not part of this seam.
|
||||
pub(crate) fn stop_attack_local(
|
||||
engaged: bool,
|
||||
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
|
||||
queued_melee: &mut crate::spell::QueuedMeleeSpell,
|
||||
net: &crate::net::NetCommands,
|
||||
) {
|
||||
if !engaged {
|
||||
@@ -369,8 +364,8 @@ pub(crate) fn stop_attack_local(
|
||||
}
|
||||
}
|
||||
|
||||
/// The one local **StartAttack** — benilla's `0x5ecb70`, [`stop_attack_local`]'s twin (wow-re §5
|
||||
/// `melee-autorepeat-exclusion.md` §5c–§5f). Two halves, and keeping them apart is the whole point:
|
||||
/// The one local **StartAttack** — benilla's `0x5ecb70`, [`stop_attack_local`]'s twin. Two halves,
|
||||
/// and keeping them apart is the whole point:
|
||||
///
|
||||
/// - **the send** (`0x5eccfd`) is gated by the already-attacking test at `0x5eccda` — skipped when
|
||||
/// the attack lock is held, *unless* a stop is in flight (`[+0xc54]`), which is why
|
||||
@@ -395,7 +390,7 @@ pub(crate) fn start_attack_local(
|
||||
target: u64,
|
||||
engaged: bool,
|
||||
stop_in_flight: bool,
|
||||
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
|
||||
auto_repeat: &mut crate::spell::AutoRepeatActive,
|
||||
sheath: &mut MessageWriter<SheathRequest>,
|
||||
commands: &mut Commands,
|
||||
net: &crate::net::NetCommands,
|
||||
@@ -433,7 +428,7 @@ pub(crate) fn start_attack_local(
|
||||
/// pseudo-spell runs (`TryCast`'s effect-`0x4e` short-circuit at `0x6e4c7a` dispatches straight to
|
||||
/// it, ahead of every cast gate). It forks on the attack lock and nothing else: already attacking →
|
||||
/// `0x6131d9 call 0x5ecac0` [`stop_attack_local`], otherwise `0x6131ee call 0x5ecb70`
|
||||
/// [`start_attack_local`] (wow-re `melee-autorepeat-exclusion.md` §5f).
|
||||
/// [`start_attack_local`].
|
||||
///
|
||||
/// The fork is the whole content, and it is worth a name because both halves used to be wrong here:
|
||||
/// the button never toggled melee OFF, and it cancelled a running auto-repeat on *every* press —
|
||||
@@ -447,8 +442,8 @@ pub(crate) fn toggle_attack_local(
|
||||
entity: Entity,
|
||||
target: u64,
|
||||
engaged: bool,
|
||||
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
|
||||
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
|
||||
queued_melee: &mut crate::spell::QueuedMeleeSpell,
|
||||
auto_repeat: &mut crate::spell::AutoRepeatActive,
|
||||
sheath: &mut MessageWriter<SheathRequest>,
|
||||
commands: &mut Commands,
|
||||
net: &crate::net::NetCommands,
|
||||
@@ -476,13 +471,13 @@ pub(crate) fn toggle_attack_local(
|
||||
/// The targeting side needs exactly this: `target::scan`'s `commit` runs the reference's
|
||||
/// stop → select → re-swing law (`SetSelection 0x493540`'s own `0x493a08 call 0x5ecac0` and
|
||||
/// `0x4938c8 call 0x5ecb70`), so every selection writer that can fire it has to carry both seams'
|
||||
/// inputs. [`crate::ui_action::cast_send::CastLadder`] already carries them field by field for the
|
||||
/// inputs. [`crate::spell::cast_send::CastLadder`] already carries them field by field for the
|
||||
/// cast path and calls the free functions directly.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct AttackSeam<'w, 's> {
|
||||
pub(crate) net: Res<'w, crate::net::NetCommands>,
|
||||
pub(crate) queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
|
||||
pub(crate) auto_repeat: ResMut<'w, crate::ui_action::AutoRepeatActive>,
|
||||
pub(crate) queued_melee: ResMut<'w, crate::spell::QueuedMeleeSpell>,
|
||||
pub(crate) auto_repeat: ResMut<'w, crate::spell::AutoRepeatActive>,
|
||||
pub(crate) sheath: MessageWriter<'w, SheathRequest>,
|
||||
pub(crate) ecs: Commands<'w, 's>,
|
||||
/// Our own entity — the sheath snap's and the auto-repeat cancel's subject.
|
||||
@@ -530,7 +525,7 @@ pub(crate) struct Casting {
|
||||
/// The client's PlayAnimation **call order**, reconstructed. The real client has no pecking
|
||||
/// order between a swing and a spell kit's anim landing in the same frame — its handlers run
|
||||
/// synchronously in packet order and the LATER `PlayAnimation` call simply overwrites bone 0
|
||||
/// (wow-re, the Eviscerate-timing round's arm C). benilla's swing and cast pipelines are
|
||||
/// (`0x5fe2f0`). benilla's swing and cast pipelines are
|
||||
/// separate message streams, so that order must ride the messages: every emitter of an
|
||||
/// animation-bearing request ([`SwingMessage`], [`CastEvent`], [`EmoteAnim`], [`KitPush`])
|
||||
/// stamps `next()` at emission — the wire drain stamps in packet order; scene-time emitters
|
||||
@@ -581,13 +576,13 @@ pub(crate) enum CastEventKind {
|
||||
Impact { weapon_visual: Option<u32> },
|
||||
/// A projectile arrived at a **ground point** instead of on a unit — the client's per-tick
|
||||
/// missile dispatch taking `0x61e1d0` → `0x61d870`, which is the **no-live-target** arm, not
|
||||
/// a ground arm as such (a target that despawned mid-flight lands there too; wow-re
|
||||
/// `missile-arrival-dispatch.md`). Ours is reached only by the single
|
||||
/// a ground arm as such (a target that despawned mid-flight lands there too). Ours is reached
|
||||
/// only by the single
|
||||
/// missile a dest-targeted GO with an empty hit list launches
|
||||
/// ([`spell_visual::MissileSpawn::ground_aim`]). `entity` is the **caster** — `0x61d870`
|
||||
/// plays the kit on it, not on anything at the point — and `pos` is the arrival position,
|
||||
/// the client's `extra` override (`0x61d8e7`: `lea edx,[esi+0x20]`). Plays `SpellVisual`
|
||||
/// **field 13 at stage 3** (wow-re `spell-visual-lifecycle.md` §Q4).
|
||||
/// **field 13 at stage 3**.
|
||||
GroundImpact { pos: Vec3 },
|
||||
}
|
||||
|
||||
@@ -605,8 +600,7 @@ pub(crate) struct CastHold {
|
||||
pub(crate) spell_id: u32,
|
||||
/// A ranged-slot shot's wind-up (`Attributes & 0x2` — Throw's ReadyThrown, the bow's
|
||||
/// LoadBow): the client brackets every ranged kit play with ranged snaps (`0x60f34c`
|
||||
/// before, the outer `0x6e5930`/`0x6e78f3` after — wow-re
|
||||
/// `ranged-sheath-exempt-autorepeat.md`), so while this hold is live the sheath
|
||||
/// before, the outer `0x6e5930`/`0x6e78f3` after), so while this hold is live the sheath
|
||||
/// reconcile's force-stow never survives the frame ([`driver`]'s bracket override).
|
||||
pub(crate) ranged: bool,
|
||||
}
|
||||
@@ -614,7 +608,7 @@ pub(crate) struct CastHold {
|
||||
/// Per-hand weapon-grip state: whether a weapon is held in the right/left hand, so that hand's fingers
|
||||
/// curl into a grip. The real client arms `HandsClosed` (AnimationData 15) on a hand's finger key-bones
|
||||
/// **purely by that hand's attach point being occupied by a weapon** — not combat, not sheath state; a
|
||||
/// forearm-mounted shield or an empty hand stays open (wow-re `hand-grip-mechanism.md`, `0x60b590` /
|
||||
/// forearm-mounted shield or an empty hand stays open (`0x60b590` /
|
||||
/// paperdoll `0x5059a0`). Written by the held-item resolver ([`crate::entities`], decision 0072) from the
|
||||
/// weapon's hand attach point; read by [`driver::drive_hand_grip`] to hold/release the finger overlay.
|
||||
#[derive(Component, Default, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -681,7 +675,7 @@ pub(crate) struct EmoteAnim {
|
||||
}
|
||||
|
||||
/// A **state kit's animation id, which is a comparison and never a play** — `PlaySpellVisualKit`'s
|
||||
/// stage-2 leg (VERIFIED, wow-re `state-kit-anim-and-stun-pose.md` §1/§2; decision 2085).
|
||||
/// stage-2 leg (decision 2085).
|
||||
///
|
||||
/// `0x60edf0`'s tail has exactly one site that hands a kit's `+0x8` to the play primitive
|
||||
/// (`0x60f3c5 call 0x5fe2f0`), and **stage 2 is diverted around it**: `0x60f387 jne` takes the
|
||||
@@ -695,8 +689,8 @@ pub(crate) struct EmoteAnim {
|
||||
/// It is therefore an animation-**cutting** mechanism, and the cut is not incidental: Charge
|
||||
/// (22911) plays `Knockdown`(121) from its impact kit and then cuts it with its own state kit's
|
||||
/// `Stun`(14), because 121 ≠ 14 forces the recompute. That is also why no stun ever shows the
|
||||
/// `Stun` pose despite 23 kits naming it — the selector `0x5fd8b0` cannot produce 14 (its literal
|
||||
/// outputs are enumerated in the note), so the recompute lands on `Stand`.
|
||||
/// `Stun` pose despite 23 kits naming it — the selector `0x5fd8b0` cannot produce 14 (14 is not
|
||||
/// among its literal outputs), so the recompute lands on `Stand`.
|
||||
///
|
||||
/// The driver holds the comparison because the armed id lives there. An aura **refresh** in place
|
||||
/// emits nothing: the watcher `0x604d00` fires neither arm when a slot is rewritten with the same
|
||||
@@ -709,7 +703,7 @@ pub(crate) struct BaseAnimRecompute {
|
||||
}
|
||||
|
||||
/// Rear up a rider's mount (decision 0441 P2): resolved to a [`EmoteAnim`] one-shot of
|
||||
/// MountSpecial(94) on the unit's MOUNT CHILD entity by [`flourish_to_anim`] — the §5-verified
|
||||
/// MountSpecial(94) on the unit's MOUNT CHILD entity by [`flourish_to_anim`] — `0x608d50`'s
|
||||
/// routing (the flourish plays on the mount model; the rider holds Mount(91) throughout).
|
||||
/// Written by the net drain (`SMSG_MOUNTSPECIAL_ANIM` — observed riders only; our own echo
|
||||
/// is dropped there) and by the self space-bar gate (`crate::player`, locally at send time).
|
||||
@@ -776,15 +770,15 @@ pub(crate) struct SpellGoTargets {
|
||||
pub(crate) hits: Vec<Entity>,
|
||||
/// Units it missed, with the wire's `SpellMissInfo` code — a projectile still flies at
|
||||
/// each, and its **arrival** plays the victim's defense clip for DODGE(3)/BLOCK(5) (the
|
||||
/// client's `Missile_C::Update 0x61ceb0` dispatch — Dodge 30 / ShieldBlock 24, never Parry;
|
||||
/// wow-re `smsg-attackerstate-consequences.md` §Q4). No impact kit plays. The deflect
|
||||
/// client's `Missile_C::Update 0x61ceb0` dispatch — Dodge 30 / ShieldBlock 24, never Parry).
|
||||
/// No impact kit plays. The deflect
|
||||
/// *flight* visual (the projectile glancing off) stays a named approximation — ours ends
|
||||
/// the flight at the target.
|
||||
pub(crate) misses: Vec<(Entity, u8)>,
|
||||
/// The ground point a dest-targeted cast launched at (`TARGET_FLAG_DEST_LOCATION`), in
|
||||
/// **bevy** coords (converted at the apply seam like every scene position). A ground AOE's
|
||||
/// GO arrives with empty hit/miss lists and only this. No consumer reads it yet — the
|
||||
/// dest-anchored launch visual is pending the dispatched wow-re read (the persistent area
|
||||
/// dest-anchored launch visual is still an open question (the persistent area
|
||||
/// effect anchors to the DynamicObject create, not to this packet).
|
||||
pub(crate) dest: Option<Vec3>,
|
||||
/// The GO's ammo block (`castFlags & 0x20`, a ranged shot): the `ItemDisplayInfo` id of the
|
||||
@@ -847,6 +841,7 @@ pub(crate) use impact::{DefenseAnim, PendingImpacts, SwingFlush, SwingImpact, Sw
|
||||
/// one-shots ([`EmoteAnim`]) and kit sounds ([`spell_visual::SpellKitSound`]).
|
||||
mod blood;
|
||||
mod env_damage;
|
||||
pub(crate) mod net;
|
||||
pub(crate) mod spell_visual;
|
||||
use blood::{blood_spurts, load_blood_tables};
|
||||
use env_damage::{hard_landing_dust, load_env_damage_table};
|
||||
@@ -912,8 +907,8 @@ pub(crate) struct AnimDriver {
|
||||
/// (`0x5fe48e`, the fields' only reader), and `0x5fe2f0` is the **single** animation entry
|
||||
/// point in the image — 40 call sites, locomotion among them (`0x602c60` → `0x5fd9e0` →
|
||||
/// `0x5fd8b0` → `0x5fd100`), which is why this is not a one-shot flag: a unit that simply
|
||||
/// starts running consumes the arm, and that is what starts Charge's trail (wow-re
|
||||
/// `charproc8-trail-draw-state.md` §11.5/§11.6). benilla's driver is one batched system, not
|
||||
/// starts running consumes the arm, and that is what starts Charge's trail.
|
||||
/// benilla's driver is one batched system, not
|
||||
/// a per-play function, so the arm cannot be read at the call the way the reference reads it.
|
||||
started_anim: bool,
|
||||
/// A **masked upper-body one-shot** in flight (decision 0087): a swing/emote the live-state route
|
||||
@@ -952,15 +947,15 @@ pub(crate) struct AnimDriver {
|
||||
/// the landing of a bracket-less step-off fall (which must still run the `0x602c60` land pick
|
||||
/// even though no Special drove the arc).
|
||||
was_falling: bool,
|
||||
/// The **deferred combat one-shot** — the client's `CGUnit+0xd60` cache (wow-re
|
||||
/// `combat-anim-fastpath.md`, decision 0406): a combat clip requested while another combat
|
||||
/// The **deferred combat one-shot** — the client's `CGUnit+0xd60` cache (decision 0406):
|
||||
/// a combat clip requested while another combat
|
||||
/// clip plays is NOT armed — the playing clip's rate doubles (op6 2.0f) and the request
|
||||
/// parks here, played by the driver the moment no one-shot is live (the client's
|
||||
/// base-recompute read). Any normal arm clears it (the client's `0x5fe48e` writes −1 on
|
||||
/// every non-fast-path PlayAnimation; the driver clears at its play sites).
|
||||
deferred: Option<u16>,
|
||||
/// The armed looping arm's **replay window** (decision 0516 — wow-re `loop-replay-fidget`
|
||||
/// §7/§7d): the variation node the loop armed + its rolled budget `R` (the client's
|
||||
/// The armed looping arm's **replay window** (decision 0516): the variation node the loop
|
||||
/// armed + its rolled budget `R` (the client's
|
||||
/// `block+0xbc`; the window is `R` clip-lengths wide, op4 `0x7126d8` — live for LOOPS too,
|
||||
/// correcting 0117's "loops ignore it"). The per-frame watchdog (`0x719370`'s transcription
|
||||
/// in `drive_animations`) re-arms the id when the node — still the MAIN armed animation —
|
||||
@@ -1043,9 +1038,9 @@ struct Overlay {
|
||||
looping: bool,
|
||||
}
|
||||
|
||||
/// A key-bone cross-fade in flight ([`AnimDriver::overlay_fade`], decision 0878 — wow-re
|
||||
/// `oneshot-lifecycle.md` §5.4). `out` is the **retiring** node, holding the outgoing pose the
|
||||
/// client snapshots into the bone's secondary slot (`rep movsd +0x98 → +0xc4`); it is `None` when
|
||||
/// A key-bone cross-fade in flight ([`AnimDriver::overlay_fade`], decision 0878). `out` is the
|
||||
/// **retiring** node, holding the outgoing pose the client snapshots into the bone's secondary
|
||||
/// slot (`0x7123af`: `rep movsd +0x98 → +0xc4`); it is `None` when
|
||||
/// a fresh clip fades in over the *inherited base* pose, which needs no node of its own — the base
|
||||
/// is already there. `left`/`total` are the window: the fixed 150 ms of a fade-to-rest, or the
|
||||
/// incoming clip's own blendTime on a blended re-arm.
|
||||
@@ -1104,7 +1099,7 @@ impl AnimDriver {
|
||||
/// The `AnimationData.dbc` id of the clip this unit is currently playing (target gait, a Special's
|
||||
/// enter/loop/exit one-shot, or Death — which the death override records as the gait). `None` before
|
||||
/// the first selection. The mouse pick reads it for the **current animation's** bounds sphere — the
|
||||
/// real client's broad-phase volume tracks the playing sequence (wow-re pick-volume RE).
|
||||
/// real client's broad-phase volume tracks the playing sequence (`0x7089c0`).
|
||||
pub(crate) fn active_anim(&self) -> Option<u16> {
|
||||
match self.mode {
|
||||
Mode::Gait => self.gait,
|
||||
@@ -1165,6 +1160,7 @@ pub(crate) struct CreatureAnimPlugin;
|
||||
|
||||
impl Plugin for CreatureAnimPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
twist::plugin(app);
|
||||
lod::plugin(app);
|
||||
breath::register(app);
|
||||
@@ -1229,7 +1225,7 @@ impl Plugin for CreatureAnimPlugin {
|
||||
arm_mount_poof_fx,
|
||||
// The aura-slot watcher: state kits persist for the aura's life (the bread).
|
||||
arm_aura_state_fx,
|
||||
// The pending-morph latch (wow-re `shapeshift-morph-cloud.md`): the aura
|
||||
// The pending-morph latch (`0x5ff0c0` arms, `0x60abe0` replays): the aura
|
||||
// add/remove edges arm it, and a display swap's rebuild replays the latched
|
||||
// spell's impact kit — the shapeshift cloud, both directions. Arm before
|
||||
// replay: the swap message crosses from last frame's teardown, and its
|
||||
@@ -1314,7 +1310,7 @@ impl Plugin for CreatureAnimPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `id` to the id this model actually plays (decision 0082, wow-re `anim-id-resolution.md`):
|
||||
/// Resolve `id` to the id this model actually plays (decision 0082, `0x711bf0`):
|
||||
/// [`ModelAnimations::resolve`] via `catalog` when `AnimationData.dbc` has loaded, else identity — a
|
||||
/// brief window at startup ([`load_anim_data`](sheath::load_anim_data) runs at `Startup`, so this
|
||||
/// degrade is only ever live for the first few frames, same shape as `anim_data.map_or(0, ..)`
|
||||
@@ -1356,13 +1352,13 @@ mod attack_stand_tests {
|
||||
// A dead-letter net channel: the send is `let _ = …` and no packet is under test here.
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(crate::net::NetCommands(tx));
|
||||
app.insert_resource(crate::ui_action::AutoRepeatActive(None));
|
||||
app.insert_resource(crate::spell::AutoRepeatActive(None));
|
||||
|
||||
let me = app.world_mut().spawn_empty().id();
|
||||
app.add_systems(
|
||||
Update,
|
||||
move |mut commands: Commands,
|
||||
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
|
||||
mut auto_repeat: ResMut<crate::spell::AutoRepeatActive>,
|
||||
mut sheath: MessageWriter<SheathRequest>,
|
||||
net: Res<crate::net::NetCommands>| {
|
||||
start_attack_local(
|
||||
@@ -1397,7 +1393,7 @@ mod attack_stand_tests {
|
||||
mod nock_latch_tests {
|
||||
use super::*;
|
||||
|
||||
/// The `$BWP`/`$BWR` nock cycle (wow-re `nocked-ammo-cancel.md` §G1/G4): the pull keyframe
|
||||
/// The `$BWP`/`$BWR` nock cycle (set `0x624b70`, cleared `0x60016c`): the pull keyframe
|
||||
/// latches (only a unit with a cached ammo display), the release keyframe clears — the arrow
|
||||
/// appears as she draws it and leaves the hand with the shot.
|
||||
#[test]
|
||||
@@ -1452,7 +1448,7 @@ mod nock_latch_tests {
|
||||
mod disarm_tests {
|
||||
use super::*;
|
||||
|
||||
/// **The ladder, hand by hand** (decision 1863; wow-re `disarm-weapon-gate-law.md` §2c). The
|
||||
/// **The ladder, hand by hand** (decision 1863; `0x5ec240`/`0x605e30`). The
|
||||
/// reference hides **exactly one weapon**, main hand first — so a disarmed dual-wielder
|
||||
/// punches with the main hand and still swings its off-hand weapon. Every selector downstream
|
||||
/// reaches its own unarmed leg by its own table; there is no disarm case in any of them.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The melee **blood spurt** (decision 0137 phase 3) — the fourth element of the victim-feedback
|
||||
//! set (wow-re `melee-blood-spurt.md`, byte-verified `0x624530 → 0x625010`): a landed melee blow
|
||||
//! set (`0x624530 → 0x625010`): a landed melee blow
|
||||
//! hangs a small particle-emitter model (`Particles\BloodSpurts\*.mdx`) on the victim, front or
|
||||
//! back by where the attacker stands, sized by the crushing bit, colored by the creature's blood
|
||||
//! type. Independent of the wound flinch and the floating text; rides the 0122 kit-effect spawn
|
||||
@@ -30,7 +30,7 @@ use super::{SpellKitFx, SwingImpact, SwingMessage};
|
||||
/// `cvars::REGISTERED` takes one row per knob a settings page actually wires (0137 phase 3).
|
||||
const VIOLENCE_LEVEL: usize = 2;
|
||||
|
||||
/// The victim-model M2 attachment ids the spurt hangs on (`melee-blood-spurt.md`: CEffect at
|
||||
/// The victim-model M2 attachment ids the spurt hangs on (`0x625010`: CEffect at
|
||||
/// attach tag 0xf front / 0x10 back — present on every character and creature model checked).
|
||||
const ATTACH_FRONT: u16 = 15;
|
||||
const ATTACH_BACK: u16 = 16;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! **Cold breath** — the visible vapour a unit puffs from its mouth in a cold zone (B233,
|
||||
//! decision 1149; wow-re `object-layer/scratch/cold-breath-law.md`, §5 two independent pairs +
|
||||
//! byte arbitration).
|
||||
//! decision 1149).
|
||||
//!
|
||||
//! The client hangs three mutually-exclusive puffs off one animation event, and they are the
|
||||
//! `$BTH` family: cold vapour, underwater bubbles, and the drunk's bubbles. This module owns the
|
||||
@@ -41,7 +40,7 @@
|
||||
//! ## Stated divergences
|
||||
//!
|
||||
//! - **The underwater rung is not modelled** — but it is now fully specified rather than open:
|
||||
//! what is missing is the work, not the law (`cold-breath-law.md` §4b). The test is
|
||||
//! what is missing is the work, not the law (`0x607710`'s liquid arm). The test is
|
||||
//!
|
||||
//! ```text
|
||||
//! underwater ⟺ 5.0 + SCALE_X · boxHeight < liquidSurfaceZ − unitZ
|
||||
|
||||
@@ -59,7 +59,7 @@ use wound::{wound_evict, wound_trigger, wound_upkeep, WoundEdge};
|
||||
/// value as the per-arm sheath ceremony's overlay weight.
|
||||
const ONESHOT_OVERLAY_WEIGHT: f32 = 8.0;
|
||||
|
||||
/// The key-bone **fade-to-rest** window (decision 0878 — wow-re `oneshot-lifecycle.md` §5.4): a
|
||||
/// The key-bone **fade-to-rest** window (decision 0878): a
|
||||
/// finished upper-body one-shot is never stopped. The client's per-frame advance latches the
|
||||
/// completion (`0x719370`), the deferred event reaches `CGUnit::OnAnimationFinished 0x5fc920` the
|
||||
/// same frame, and that calls op4 with `param_3 = -1` (`0x5fcacb`) — which snapshots the clip's
|
||||
@@ -83,7 +83,7 @@ enum OneShotReq {
|
||||
}
|
||||
|
||||
/// The currently-armed one-shot the combat fast-path tests — the client's key-bone-else-bone-0
|
||||
/// current-id read (`0x5fe422`, wow-re `combat-anim-fastpath.md` §1): the masked overlay while
|
||||
/// current-id read (`0x5fe422`): the masked overlay while
|
||||
/// its node still plays, else the full-body [`Mode::Swing`] clip while unfinished. Gait and
|
||||
/// Special clips are never returned; their ids aren't combat, so nothing is lost at the
|
||||
/// classifier gate.
|
||||
@@ -194,8 +194,7 @@ fn overlay_fade_upkeep(drv: &mut AnimDriver, player: &mut AnimationPlayer, dt: f
|
||||
}
|
||||
}
|
||||
|
||||
/// The **TRANSPLANT** (`0x5fe919` — wow-re `oneshot-lifecycle.md` §3a, the half
|
||||
/// `anim-composition-model.md` §2 was missing; decision 0878). A base **locomotion** clip
|
||||
/// The **TRANSPLANT** (`0x5fe919`, decision 0878). A base **locomotion** clip
|
||||
/// requested while bone 0 still plays a live **CAST** or **COMBAT** one-shot does not replace it:
|
||||
/// the client copies the bone-0 descriptor — its id, its rate, and `+0x08` the clip's **live
|
||||
/// elapsed position** — onto the key-bone with `blendFlag = 0`, then hands bone 0 the request. The
|
||||
@@ -319,8 +318,8 @@ pub(super) fn drive_animations(
|
||||
// …and the host's granted modes, for the same reason `unify` reads them on the rider.
|
||||
Option<&crate::net::UnitMoveModes>,
|
||||
// …and the rider's own `OBJECT_FIELD_SCALE_X`: a mount child's transform carries only its
|
||||
// `CreatureDisplayInfo` column, and the mount renders at the PRODUCT of the two (the
|
||||
// byte-verified mount composition, wow-re `0x613ef0`). The rate divisor wants that product
|
||||
// `CreatureDisplayInfo` column, and the mount renders at the PRODUCT of the two
|
||||
// (`0x613ef0`). The rate divisor wants that product
|
||||
// — the mount model's world scale — not the child's local column (decision 0903).
|
||||
&Transform,
|
||||
// …and whether the rider is US, which only the debug trace reads (decision 0906).
|
||||
@@ -564,7 +563,7 @@ pub(super) fn drive_animations(
|
||||
if movement.is_none() {
|
||||
mv.stand_state = store.map_or(0, |s| s.0.unit_stand_state());
|
||||
}
|
||||
// Mounted forces stand-state 0 on every recompute (`0x5fdf80`, wow-re sheath-policy §3):
|
||||
// Mounted forces stand-state 0 on every recompute (`0x5fdf80`):
|
||||
// no sit/sleep/kneel pose can hold in the saddle.
|
||||
if mounted {
|
||||
mv.stand_state = 0;
|
||||
@@ -578,7 +577,7 @@ pub(super) fn drive_animations(
|
||||
// mounts are mutually exclusive anyway.
|
||||
mv.stealthed = store.is_some_and(|s| s.0.unit_is_stealthed());
|
||||
let moving = mv.flags & move_flags::ANY_MOVE != 0;
|
||||
// The airborne arc's bookkeeping (wow-re land-anim-height-gate + rf57b §2): on the arc's
|
||||
// The airborne arc's bookkeeping (jump `0x60e480`, land `0x602c60`): on the arc's
|
||||
// FIRST airborne frame, its launch vertical speed splits a **jump** (upward — the client's
|
||||
// JumpStart/Jump bracket rides the MSG_MOVE_JUMP event) from a **step-off fall** (level or
|
||||
// downward — no bracket; the gait freezes until FALLINGFAR latches Fall 40, and an
|
||||
@@ -629,7 +628,7 @@ pub(super) fn drive_animations(
|
||||
if falling && (!was_falling || launched) {
|
||||
drv.jump_arc = mv.vertical_speed > JUMP_ARC_MIN_UP;
|
||||
}
|
||||
// The airborne-freeze's exact gate (`0x5fd8e8`, §5-arbitrated — decision 0868):
|
||||
// The airborne-freeze's exact gate (`0x5fd8e8`, decision 0868):
|
||||
// keep-current iff `FALLING && (FALLINGFAR || vz ≠ 0)`. The vz clause pins every real
|
||||
// arc (ballistics make vz ≠ 0 from the first integrated substep); the one uncovered
|
||||
// case is a fresh walk-off's vz == 0 substep, where the client genuinely may re-pick.
|
||||
@@ -638,7 +637,7 @@ pub(super) fn drive_animations(
|
||||
let airborne_frozen =
|
||||
falling && (mv.flags & move_flags::FALLING_FAR != 0 || mv.vertical_speed != 0.0);
|
||||
// The idle re-face turn-shuffle (decision 0123 — the client's facing-delta latch
|
||||
// `0x607ed0`, wow-re `loop-replay-fidget.md` §5b): a stationary creature easing its yaw
|
||||
// `0x607ed0`): a stationary creature easing its yaw
|
||||
// toward its target reads as *turning* to the anim layer, so the gait picks
|
||||
// ShuffleLeft/Right and each Shuffle→Stand return re-arms (and, relaxed, re-rolls) Stand
|
||||
// — the fidget's recurring trigger. Gated like the client's can-fidget gate (`0x5fce30`:
|
||||
@@ -709,8 +708,7 @@ pub(super) fn drive_animations(
|
||||
continue;
|
||||
}
|
||||
|
||||
// **The mount transition's own bone-0 arm** (wow-re `mount-cast-anim-disposition.md` §1 +
|
||||
// `mount-composition.md` Q4, §5 2026-08-03). Both legs of the `UNIT_FIELD_MOUNTDISPLAYID`
|
||||
// **The mount transition's own bone-0 arm**. Both legs of the `UNIT_FIELD_MOUNTDISPLAYID`
|
||||
// watcher arm the BODY's bone 0 through op4 `0x7121a0` on the PRIMARY slot: the build
|
||||
// `0x607b44` plays **91 `Mount`** with a cross-fade, the teardown `0x607ce0` plays seq
|
||||
// **0 `Stand`** with the cross-fade **off** (decision 0931 — the two legs are the same
|
||||
@@ -780,9 +778,8 @@ pub(super) fn drive_animations(
|
||||
drv.deferred = None; // a normal arm clears the fast-path cache (`0x5fe48e`)
|
||||
}
|
||||
|
||||
// The loot kneel's trigger (see [`select::LOOT`]; byte-verified, wow-re
|
||||
// `loot-anim-leg.md` §5/§8 — decisions 0515 / 1471 / 1477): the leg needs **two**
|
||||
// predicates, and both SPLIT on IsActivePlayer.
|
||||
// The loot kneel's trigger (see [`select::LOOT`]; decisions 0515 / 1471 / 1477): the
|
||||
// leg needs **two** predicates, and both SPLIT on IsActivePlayer.
|
||||
//
|
||||
// **Self.** Predicate A (`0x6126b0`) is "a loot session is open" — the client-local
|
||||
// loot-target latch. Predicate B (`0x612710`) is the per-object-CLASS filter: which
|
||||
@@ -818,9 +815,9 @@ pub(super) fn drive_animations(
|
||||
} else {
|
||||
current_special(&mv, drv.jump_arc)
|
||||
};
|
||||
// The loot leg outranks the standState pose resolver (the rf57 chain calls loot before
|
||||
// standState): a looting unit kneels at the loot, never into a sit/sleep bracket. The
|
||||
// airborne Specials keep their precedence (the chain's airborne freeze runs first).
|
||||
// The loot leg outranks the standState pose resolver (the `0x5fd8b0` chain calls loot
|
||||
// before standState): a looting unit kneels at the loot, never into a sit/sleep bracket.
|
||||
// The airborne Specials keep their precedence (the chain's airborne freeze runs first).
|
||||
let special = special.filter(|s| !(looting && matches!(s, select::Special::Pose(_))));
|
||||
|
||||
// ── Sheath state (decision 0080). The unit's rendered sheath is the **client-side
|
||||
@@ -829,9 +826,9 @@ pub(super) fn drive_animations(
|
||||
// *changes* (the `0x604c70` field-apply — a remote unit's own client volunteered the
|
||||
// change; our own echo arrives already-committed and adopts as a no-op), and written by
|
||||
// the one-setter requests here + the per-animation reconcile below. Every transition
|
||||
// **snaps by default** — byte-verified across all 24 SetSheatheState call sites (wow-re
|
||||
// `sheath-policy.md`): only the manual toggle's request carries the ceremony, and the
|
||||
// draw/stow sound rides the ceremony playback (no clip → no sound).
|
||||
// **snaps by default** — across all 24 call sites of SetSheatheState `0x611cf0`: only the
|
||||
// manual toggle's request carries the ceremony, and the draw/stow sound rides the ceremony
|
||||
// playback (no clip → no sound).
|
||||
let sheath_frame_start = drv.sheath_cur;
|
||||
let sheath_byte = store.and_then(|s| s.0.unit_sheath_state()).unwrap_or(0);
|
||||
if drv.sheath_cur.is_none() || drv.sheath_byte != Some(sheath_byte) {
|
||||
@@ -849,7 +846,7 @@ pub(super) fn drive_animations(
|
||||
// stow point). A ceremony without playable clips degrades to the snap.
|
||||
if let Some(req) = pending_sheath.get(&entity) {
|
||||
let cur = drv.sheath_cur.unwrap_or(0);
|
||||
// Mounted is a persistent DRAW-BLOCK (wow-re `sheath-policy.md` §3: the client's
|
||||
// Mounted is a persistent DRAW-BLOCK (the client's
|
||||
// recompute `0x5fdf80` tests the mount model FIRST and forces stowed on every
|
||||
// PlayAnimation — nothing can stay drawn on a mount). The client's composite
|
||||
// recomputes constantly (the mount's own gait plays); our rider track goes silent
|
||||
@@ -917,8 +914,8 @@ pub(super) fn drive_animations(
|
||||
// (op4 `blendFlag≠0` copies the outgoing pose over `+0xc4..`). So this frame's plays are
|
||||
// tracked — full-body plays (bone 0: the base track) evict a full-body wound; masked-slot
|
||||
// plays (the key-bone) evict a masked wound. A play on the *other* bone leaves the wound
|
||||
// decaying (the §5's inherited-swing case). Mode/gait changes proxy the mode machine's own
|
||||
// base plays; the flags catch the same-id re-plays the proxy can't see.
|
||||
// decaying (the kernel `0x714260`'s inherited-swing case). Mode/gait changes proxy the
|
||||
// mode machine's own base plays; the flags catch the same-id re-plays the proxy can't see.
|
||||
// ── The **base-animation lock**'s clearer, keyed on the FINISHED id and run before
|
||||
// anything re-picks the base this frame — the reference clears at `0x5fc9c6`, above
|
||||
// `OnAnimationFinished`'s own reason branch, so completion and pre-emption both release it
|
||||
@@ -947,7 +944,7 @@ pub(super) fn drive_animations(
|
||||
return None;
|
||||
}
|
||||
// `DO_NOT_PLAY_WOUND_ANIM` takes the **parry** with the flinch (`0x60ec1f`, the bit's
|
||||
// second and last consumer — wow-re `wound-parry-gate-and-injury-vocal.md` Q1/Q6).
|
||||
// second and last consumer).
|
||||
// The gate is inside `0x60ec00`, which the `$CPP` ladder enters only on
|
||||
// victimState 3: DODGE/DEFLECT (30) and BLOCK (24) go straight to PlayAnimation from
|
||||
// `0x624a90`/`0x624a74` and are NOT gated — a flagged creature still dodges and still
|
||||
@@ -1012,7 +1009,7 @@ pub(super) fn drive_animations(
|
||||
// hit re-parks its own request — both what the client's `0x5fe48e`/`0x5fe480` do).
|
||||
// NEVER mid-air: the read (`0x5fd392`, inside the `0x5fd360` recompute arm) sits
|
||||
// downstream of the airborne-freeze, so a park made mid-arc waits — and dies at the
|
||||
// landing play's clear (§5-verified, decision 0868).
|
||||
// landing play's clear (decision 0868).
|
||||
if requests.is_empty()
|
||||
&& drv.deferred.is_some()
|
||||
&& !airborne_frozen
|
||||
@@ -1028,7 +1025,7 @@ pub(super) fn drive_animations(
|
||||
if drv.base_lock.refuses() {
|
||||
continue;
|
||||
}
|
||||
// The COMBAT FAST-PATH (`0x5fe43c`–`0x5fe48b`, wow-re `combat-anim-fastpath.md`,
|
||||
// The COMBAT FAST-PATH (`0x5fe43c`–`0x5fe48b`,
|
||||
// decision 0406): a combat clip requested while another combat clip is playing is
|
||||
// NOT armed — the CURRENT clip's rate doubles (op6 2.0f re-times its remainder,
|
||||
// pose-continuous) and the request parks in the `+0xd60` cache to play afterwards.
|
||||
@@ -1075,7 +1072,7 @@ pub(super) fn drive_animations(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Mounted forces the masked route (byte-verified, wow-re mount-composition B1:
|
||||
// Mounted forces the masked route (`0x5fe2f0`'s mounted branch:
|
||||
// upper-body one-shots route to the key-bone while mounted, and the 91-force would
|
||||
// reclaim bone 0 on the next play anyway): a full-body /wave would replace the seat
|
||||
// pose with a standing wave floating over the saddle.
|
||||
@@ -1097,7 +1094,7 @@ pub(super) fn drive_animations(
|
||||
// swaps the torso in one frame. Note the full-body branch below deliberately does
|
||||
// NOT touch the overlay: on that route the client's key-bone slot keeps its current
|
||||
// descriptor (a dedup no-op), so a standing emote over a still-running masked swing
|
||||
// plays legs-only underneath it (`0x5fe930`; wow-re claim 6 CONFIRMED).
|
||||
// plays legs-only underneath it (`0x5fe930`).
|
||||
retire_overlay(
|
||||
&mut drv,
|
||||
&mut player,
|
||||
@@ -1119,7 +1116,7 @@ pub(super) fn drive_animations(
|
||||
// Full-body route (standing idle / airborne non-combat), or the split-boneless
|
||||
// masked fallback (the client's −1 key-bone sentinel arms bone 0 too): the clip
|
||||
// replaces the base on bone 0 — **even over a Special**. The client never drops a
|
||||
// play: one slot, last-writer-wins (decisions 0083/0087; the wow-re §3 route puts
|
||||
// play: one slot, last-writer-wins (decisions 0083/0087; the route `0x5fe6c8` puts
|
||||
// a jump-in-place cast/emote on bone 0, replacing the hang — decision 0864 is the
|
||||
// ref's mid-air cast). Cutting an airborne clip freezes the outgoing node first —
|
||||
// the pose-snapshot decay of the client's op4 blend, scoped exactly like
|
||||
@@ -1186,7 +1183,7 @@ pub(super) fn drive_animations(
|
||||
if special_edge {
|
||||
drv.deferred = None;
|
||||
}
|
||||
// The looping-variation ADVANCE (decision 0516 — wow-re `loop-replay-fidget.md` §7/§7d,
|
||||
// The looping-variation ADVANCE (decision 0516 —
|
||||
// the per-frame watchdog `0x719370`): every looping arm installed a window `R`
|
||||
// clip-lengths wide; when the armed node — still the MAIN animation (the client checks
|
||||
// the armed block: any newer arm superseded the window) — completes its `R` passes, the
|
||||
@@ -1341,7 +1338,7 @@ pub(super) fn drive_animations(
|
||||
if done {
|
||||
// Freeze the held frame, then retire it: the client's snapshot copies an already
|
||||
// *expired* window, so the secondary clamps to `seq.end` and the fade runs from
|
||||
// the clip's final pose (`oneshot-lifecycle.md` §5.4).
|
||||
// the clip's final pose (`0x7123af`).
|
||||
if let Some(a) = player.animation_mut(ov.node) {
|
||||
a.set_speed(0.0);
|
||||
}
|
||||
@@ -1480,18 +1477,18 @@ pub(super) fn drive_animations(
|
||||
// 1. `0x60ea9f` → `0x6125f0`: the victim's cached creature template carries
|
||||
// `type_flags` bit `0x8`, **DO_NOT_PLAY_WOUND_ANIM** — a skeleton, a ghost, a
|
||||
// bone golem has no flesh to recoil, and the reference refuses every flinch it
|
||||
// would ever take, melee and spell alike (decision 2068; wow-re
|
||||
// `melee-blood-spurt-suppression.md` §6). It gates the ANIMATION only: the blood
|
||||
// spurt is a separate system reading [`SwingImpact`] itself, and wow-re §8 Q1 is
|
||||
// explicit that nothing keyed on creature type suppresses it — a skeleton bleeds.
|
||||
// would ever take, melee and spell alike (decision 2068). It gates the ANIMATION
|
||||
// only: the blood spurt is a separate system reading [`SwingImpact`] itself, and
|
||||
// nothing keyed on creature type suppresses it (`0x624530` → `0x625010`) — a
|
||||
// skeleton bleeds.
|
||||
// A record we have not received yet reads as NOT flagged, which is `0x6125f0`'s
|
||||
// own null-record leg (`return record ? … : false`), so a creature whose query is
|
||||
// still in flight flinches there too.
|
||||
// 2. `0x60eaac`–`0x60eac8` (wow-re `charproc-rate-override-wound-gate.md`, decision
|
||||
// 2063): a CharProc-11 rate-override node on the unit's effect list — the freeze
|
||||
// auras' node, Freezing Trap / Ice Block / petrify / web wrap — refuses EVERY
|
||||
// flinch for as long as it lives. The gate is the node's presence, not its rate
|
||||
// (kit 3071's 1.0 gates too), so it reads the node list, not the pause it applies.
|
||||
// 2. `0x60eaac`–`0x60eac8` (decision 2063): a CharProc-11 rate-override node on the
|
||||
// unit's effect list — the freeze auras' node, Freezing Trap / Ice Block /
|
||||
// petrify / web wrap — refuses EVERY flinch for as long as it lives. The gate is
|
||||
// the node's presence, not its rate (kit 3071's 1.0 gates too), so it reads the
|
||||
// node list, not the pause it applies.
|
||||
//
|
||||
// `no_wound_anim` is read once above, where the parry pick — the flag's other
|
||||
// consumer — needs the same answer.
|
||||
@@ -1535,15 +1532,15 @@ pub(super) fn drive_animations(
|
||||
// (gossip, loot, mounts) inherit the policy with zero new sheath wiring.
|
||||
// The reconcile tests the **requested** id, not the model-resolved substitute (decision
|
||||
// 0125, director-eyes falsification of 0082's resolved-id reading): the client's arm
|
||||
// descriptor carries the id that was ASKED FOR (the cast-override §2.1 writes the kit's
|
||||
// own anim id even on a model lacking the sequence), and `0x5fdb50` reads that
|
||||
// descriptor carries the id that was ASKED FOR (the cast-override `0x5fde80` writes the
|
||||
// kit's own anim id even on a model lacking the sequence), and `0x5fdb50` reads that
|
||||
// descriptor. The two differ only when a model lacks the requested clip — the ground
|
||||
// truth is GnollCaster.m2: no ReadySpellDirected(51) at all, its hold falls back to a
|
||||
// flags-less Stand for *playback*, yet the ref still stows the staff for the whole
|
||||
// windup — only 51's own `&4` row can do that. (0082's chicken rationale collapses under
|
||||
// the same lens: requested Attack2H's 0x20 vs resolved AttackUnarmed's 0x10 is
|
||||
// unobservable on a weaponless chicken.) Byte arbitration of `0x5fdb50`'s miss path is
|
||||
// dispatched to wow-re; the director's eyes outrank the old reading meanwhile.
|
||||
// unobservable on a weaponless chicken.) The byte reading of `0x5fdb50`'s miss path is
|
||||
// open; the director's eyes outrank the old reading meanwhile.
|
||||
// The reconcile must see **every play** (decision 0087 (d)): the client's `0x5fdf80` runs
|
||||
// inside `PlayAnimation` itself, so a *masked* one-shot — which never touched `mode`, so
|
||||
// `active_anim()` still reports the base gait — is reconciled all the same. A masked emote
|
||||
@@ -1577,9 +1574,8 @@ pub(super) fn drive_animations(
|
||||
if let Some(forced) =
|
||||
select::reconcile_sheath(cur, anim, flags, engaged, is_self, sheath_byte, mounted)
|
||||
{
|
||||
// The ranged wind-up **bracket** (wow-re `ranged-sheath-exempt-autorepeat.md`,
|
||||
// Q1's ordering law): the client surrounds every ranged kit play with ranged
|
||||
// snaps — `0x60f34c` before the play, the outer send/START `SetSheatheState
|
||||
// The ranged wind-up **bracket**: the client surrounds every ranged kit play with
|
||||
// ranged snaps — `0x60f34c` before the play, the outer send/START `SetSheatheState
|
||||
// (2,1,1)` after — so ReadyThrown 108's genuine force-stow (NOT in the `0x5fe180`
|
||||
// exempt set) exists but never renders: the snap always wins the frame. Our
|
||||
// frame-spread ECS can't reproduce the call-stack order, so the bracket is
|
||||
@@ -1607,7 +1603,7 @@ pub(super) fn drive_animations(
|
||||
}
|
||||
}
|
||||
// Leaving the ranged sheath stance un-nocks (the client's `0x60fc72` un-nock, gated
|
||||
// `[+0xd40] != 2`; wow-re `nocked-ammo-cancel.md`): however the state left 2 this frame
|
||||
// `[+0xd40] != 2`): however the state left 2 this frame
|
||||
// — a stow/melee request, the reconcile, the server byte — the ammo model drops.
|
||||
// Re-drawing shows no ammo until the next `SMSG_SPELL_START` re-affirms it.
|
||||
if sheath_frame_start == Some(2) && drv.sheath_cur != Some(2) {
|
||||
@@ -1617,8 +1613,7 @@ pub(super) fn drive_animations(
|
||||
}
|
||||
// …and **TRANSLATING** un-nocks too — the arrow leaves the hand and the string relaxes,
|
||||
// while the cached ammo display survives for the next pull and the weapon stays DRAWN.
|
||||
// Byte-verified (wow-re `shooter-stop-law.md` §J1.1, which refuted our recorded "nothing
|
||||
// in the client un-nocks on movement"): `0x60e480` is a movement-OPCODE dispatcher whose
|
||||
// `0x60e480` is a movement-OPCODE dispatcher whose
|
||||
// jump-table arm 0 runs `0x60e4ac call 0x60f530` (un-nock) + `RecomputeBaseAnim(-1)`, and
|
||||
// arm 0 is reached by exactly MSG_MOVE_START_{FORWARD, BACKWARD, STRAFE_LEFT,
|
||||
// STRAFE_RIGHT, SWIM} (181/182/184/185/202). **Turning and stopping do NOT un-nock** —
|
||||
@@ -1628,8 +1623,8 @@ pub(super) fn drive_animations(
|
||||
//
|
||||
// The reference's trigger is the START opcode (an edge); a level test is equivalent here
|
||||
// because the only thing that can re-latch is `$BWP`, authored solely in the Load clips —
|
||||
// which a translating unit never plays. Sheath is untouched (§J1's claim-4 split: REFUTED
|
||||
// for un-nock, CONFIRMED for stow — `[+0xd40]` has no movement-path writer).
|
||||
// which a translating unit never plays. Sheath is untouched (`[+0xd40]` has no
|
||||
// movement-path writer: only the ctor's `0x5fafa0` and `0x611cf0` write it).
|
||||
if mv.flags & (move_flags::ANY_MOVE | move_flags::SWIMMING) != 0 && nock_latched {
|
||||
commands.entity(entity).remove::<super::NockLatch>();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use bevy::prelude::*;
|
||||
use super::super::{HandGrip, HAND_GRIP_WEIGHT};
|
||||
|
||||
/// Hold each hand's `HandsClosed` finger pose while that hand grips a weapon, release it when it doesn't
|
||||
/// — the persistent weapon-grip overlay (wow-re `hand-grip-mechanism.md`: the client arms the finger
|
||||
/// — the persistent weapon-grip overlay (`CloseHand 0x479660`: the client arms the finger
|
||||
/// key-bones with `HandsClosed` purely by a weapon occupying that hand's attach point). Played *over* the
|
||||
/// gait on the finger-mask nodes, so the arm keeps its animation while only the fingers curl; on release
|
||||
/// the fingers fall back to the base gait's flat Stand rest. Idempotent — plays only on the open→closed
|
||||
|
||||
@@ -91,8 +91,8 @@ pub(super) fn run(
|
||||
Mode::Entering(sp) => {
|
||||
// The swim re-latch does NOT cut the hop's kick: JumpStart PLAYS OUT over the
|
||||
// re-latch and the swim gait resumes only at its end (decision 0517 —
|
||||
// director-corrected against the ref; the §5's static cut-at-relatch law could
|
||||
// not reproduce the screen and is flagged wow-re-side for a live capture).
|
||||
// director-corrected against the ref; the static byte reading, a cut where the freeze
|
||||
// gate `0x5fd8e8` releases, could not reproduce the screen and is unconfirmed live).
|
||||
// Only the swim re-latch holds — a ground landing, a water exit, or a new
|
||||
// Special still cuts (0503's snapshot-freeze).
|
||||
let swim_relatch_hold = sp == select::Special::Jump
|
||||
@@ -266,10 +266,10 @@ pub(super) fn run(
|
||||
} else if matches!(under, Some(select::Special::Jump | select::Special::Fall)) {
|
||||
// The airborne-freeze (`0x5fd8e8` keep-current): mid-arc nothing re-picks
|
||||
// bone 0 — a finished clip clamps and holds its last frame for the rest of
|
||||
// the arc (the §6 clamp path), and a mid-air flag change is a keep-current
|
||||
// the arc (the clamp at `0x7145db`), and a mid-air flag change is a keep-current
|
||||
// no-op. The only exits are the edges above: the FALLINGFAR latch's Fall
|
||||
// and the land pick at touchdown. This holds over Fall too: 0864's per-tick
|
||||
// Fall(40) re-assert was §5-REFUTED (decision 0868 — Fall plays ONCE, at the
|
||||
// Fall(40) re-assert was refuted (decision 0868 — Fall plays ONCE, at the
|
||||
// latch edge `0x61a820@0x61a9eb`; `0x5ff030` is a wire-apply path, not a
|
||||
// tick), so a clip that takes bone 0 after the latch holds until landing.
|
||||
} else if let Some(sp) = under {
|
||||
@@ -293,10 +293,9 @@ pub(super) fn run(
|
||||
}
|
||||
} else if oneshot_finished(player, anims, id, catalog) || mv.flags != drv.gait_flags {
|
||||
// A finished one-shot recomputes the base — the ranged FIRE clips included
|
||||
// (decision 1544, superseding 0994 §1). 0994 held them out on
|
||||
// `shooter-stop-law.md` §J4's claim that the completion dispatcher `0x5fc3f0`
|
||||
// is never reached for a bow id; wow-re's §5 refuted that as an absence proof
|
||||
// whose census could not see its second fire site — the natural-completion path
|
||||
// (decision 1544, superseding 0994 §1). 0994 held them out on the claim that
|
||||
// the completion dispatcher `0x5fc3f0` is never reached for a bow id; it is,
|
||||
// through a second fire site — the natural-completion path
|
||||
// enqueues the callback as a plain ARGUMENT (`0x7194f5` pushes mode 0) and
|
||||
// `0x7074b0` invokes it later as `call [esi+4]`, so a scan for
|
||||
// `call dword ptr [reg+0x70]` misses it by construction. 46/49/107 land on the
|
||||
@@ -367,7 +366,7 @@ pub(super) fn run(
|
||||
);
|
||||
drv.gait = None;
|
||||
} else if airborne_frozen && drv.gait.is_some_and(|g| g != DEATH) {
|
||||
// The airborne-freeze on the STEP-OFF arc — the exact §5-verified gate
|
||||
// The airborne-freeze on the STEP-OFF arc — the exact gate
|
||||
// (`0x5fd8e8`: `FALLING && (FALLINGFAR || vz ≠ 0)`, decisions 0864/0868;
|
||||
// the selector chain's leg right after death): mid-air the selector never
|
||||
// re-picks, so the takeoff-frozen gait keeps rolling mid-cycle AND the live
|
||||
@@ -461,7 +460,7 @@ pub(super) fn run(
|
||||
// (self: the latch; remote: the flag — the `looting` predicate above) on a
|
||||
// stationary, unmounted unit, the gait slot holds Loot 50 — over the cast
|
||||
// pin, the Ready/ranged idles, the chair loops and the state-emote idle
|
||||
// alike (the `0x5fd8b0` chain order, §5-verified: locomotion → LOOT →
|
||||
// alike (the `0x5fd8b0` chain order: locomotion → LOOT →
|
||||
// standState → combat/channel). The trigger dropping cross-fades back to
|
||||
// whatever the slot picks next.
|
||||
let loot_cands;
|
||||
@@ -492,7 +491,7 @@ pub(super) fn run(
|
||||
};
|
||||
let target = cands[0];
|
||||
// **The turn-shuffle is released by its own clip window, not by the turn ending**
|
||||
// (decision 1655, wow-re `object-layer/scratch/turn-shuffle-lifecycle.md`).
|
||||
// (decision 1655).
|
||||
//
|
||||
// The client's only per-frame poll of a standing unit's base animation is
|
||||
// `0x607ed0`'s tail, and it *refuses* this exact transition: with the shuffle
|
||||
@@ -519,7 +518,7 @@ pub(super) fn run(
|
||||
}
|
||||
_ => target,
|
||||
};
|
||||
// Each RF-0057 candidate, in priority order, resolved through the model's own
|
||||
// Each `0x5fd8b0` candidate, in priority order, resolved through the model's own
|
||||
// baked fallback (decision 0082) before moving to the next candidate — a model
|
||||
// missing the exact id still plays its baked substitute rather than stepping
|
||||
// down the selector's own list early. The state-emote idle's id (above) resolves
|
||||
@@ -537,8 +536,8 @@ pub(super) fn run(
|
||||
// sweeping every node of the id. A completed ranged Load simply clamps at
|
||||
// full draw and stays there; nothing promotes it (0994).
|
||||
} else if let Some(c) = clip {
|
||||
// **The two bypasses the reference keeps** (wow-re
|
||||
// `base-anim-lock-knockdown.md` §6/§7.6): the death poses and the mount attach
|
||||
// **The two bypasses the reference keeps** (`0x5fc563`,
|
||||
// `0x607b44`): the death poses and the mount attach
|
||||
// write the arm primitive directly and are not gated by the base-anim lock. In
|
||||
// benilla both arrive as ordinary gait targets, so they say so here — a body
|
||||
// knocked flat and then killed must still fall dead, and mounting during a
|
||||
@@ -557,8 +556,8 @@ pub(super) fn run(
|
||||
} else {
|
||||
// A looping base arm rolls its variation when relaxed (decision 0123 —
|
||||
// the client's base-arm `variationIdx = −1`; a combat/cast arm keeps the
|
||||
// deterministic head) AND its replay budget (decision 0516 §7d — the
|
||||
// watchdog window). A re-armed Stand landing on its rare look-around
|
||||
// deterministic head) AND its replay budget (decision 0516, `0x712784`
|
||||
// — the watchdog window). A re-armed Stand landing on its rare look-around
|
||||
// variations IS the idle fidget.
|
||||
let (c, budget) = roll_loop(anims, c, relaxed, rng);
|
||||
if traced && benilla_assets::trace::enabled() {
|
||||
@@ -618,7 +617,7 @@ pub(super) fn run(
|
||||
}
|
||||
|
||||
/// Has the base slot's armed looping clip finished its **replay window** — the client's
|
||||
/// `windowHi = windowLo + span·R` (decision 0516 §7d, wow-re `loop-replay-fidget.md` §7a)?
|
||||
/// `windowHi = windowLo + span·R` (`0x712784`, decision 0516)?
|
||||
///
|
||||
/// `true` when there is no window to wait on at all: a slot with nothing armed, or one whose
|
||||
/// window a newer arm superseded, is not something to hold a shuffle against. The driver's own
|
||||
|
||||
@@ -14,8 +14,7 @@ use super::super::{find_resolved, AnimDriver};
|
||||
use super::select::{self, jump_land_pick, Mode, Special};
|
||||
|
||||
/// The **base-animation lock** — the reference's `[unit+0xd58]` bits `0xc0000`, which are the whole
|
||||
/// reason a Lashed player visibly falls over (VERIFIED, wow-re `base-anim-lock-knockdown.md`;
|
||||
/// decision 2096, correcting 2085's §3).
|
||||
/// reason a Lashed player visibly falls over (decision 2096, correcting 2085's §3).
|
||||
///
|
||||
/// `CGUnit::PlayAnimation 0x5fe2f0` opens with a guard that returns having done nothing at all:
|
||||
///
|
||||
@@ -51,7 +50,7 @@ impl BaseAnimLock {
|
||||
}
|
||||
|
||||
/// The guard: is a play refused right now? An **unconditional early return**, not a priority
|
||||
/// comparison and not a dedup (`base-anim-lock-knockdown.md` §7.3).
|
||||
/// comparison and not a dedup (`0x5fe3a1`).
|
||||
pub(super) fn refuses(self) -> bool {
|
||||
self.0.is_some()
|
||||
}
|
||||
@@ -66,7 +65,7 @@ impl BaseAnimLock {
|
||||
/// The clearer, keyed on the **finished** id. Called once a frame before anything re-picks the
|
||||
/// base, so completion and pre-emption both release it: an overridden clip's node still reports
|
||||
/// finished at the end of its own span, which is exactly the reference's "the base stays locked
|
||||
/// for the rest of the clip's window" when a mount arms over a knockdown (§7.6).
|
||||
/// for the rest of the clip's window" when a mount arms over a knockdown (`0x607b44`).
|
||||
pub(super) fn release_finished(
|
||||
&mut self,
|
||||
player: &AnimationPlayer,
|
||||
@@ -80,12 +79,13 @@ impl BaseAnimLock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the lock outright — the reference's other two clearing sites (§7.5/§7.6).
|
||||
/// Drop the lock outright — the reference's other two clearing sites.
|
||||
///
|
||||
/// A **model rebuild** needs no call here: benilla's rebuild removes [`AnimDriver`] itself
|
||||
/// (`entities::live_display`), so the lock goes with it and the unit comes back with the base
|
||||
/// unheld — which is the reference's own outcome at `0x60adee`, reached differently. What does
|
||||
/// call it is the pair of arms the reference never gates: the death pose and the mount attach.
|
||||
/// call it is the pair of arms the reference never gates: the death pose (`0x5fc563`) and the
|
||||
/// mount attach (`0x607b44`).
|
||||
pub(super) fn release(&mut self) {
|
||||
self.0 = None;
|
||||
}
|
||||
@@ -191,7 +191,7 @@ pub(super) fn roll_oneshot<'a>(
|
||||
}
|
||||
|
||||
/// Pick a looping arm's **variation** (decision 0123 — the client's base-arm `variationIdx = −1`,
|
||||
/// wow-re `loop-replay-fidget.md` §5b): a **relaxed** arm makes the weighted `_rand` walk (the
|
||||
/// `0x5fe697`): a **relaxed** arm makes the weighted `_rand` walk (the
|
||||
/// same roll as a one-shot's — this is where a re-armed Stand lands on its rare look-around
|
||||
/// variations, the fidget); a combat/cast arm is forced to the deterministic head
|
||||
/// ([`select::arm_forces_head`] decides which, at the call site). The kernel itself never
|
||||
@@ -213,8 +213,8 @@ pub(super) fn pick_loop_variation<'a>(
|
||||
}
|
||||
|
||||
/// The looping arm's two rolls in op4's order (variation `0x71249a`, then the replay budget
|
||||
/// `0x712692..` — the same two `_rand` sites as a one-shot's; decision 0516, wow-re
|
||||
/// `loop-replay-fidget.md` §7d): the budget is live for **loops** too — not as a repeat cap but
|
||||
/// `0x712692..` — the same two `_rand` sites as a one-shot's; decision 0516): the budget is live
|
||||
/// for **loops** too — not as a repeat cap but
|
||||
/// as the watchdog **window**, `R` clip-lengths wide (`windowHi = arm + span·R`). Returns the
|
||||
/// armed clip + `R` = total passes before the watchdog re-arms (`R ∈ [min, max−1]` floored to 1
|
||||
/// — `replayMax` is exclusive, and `(0,0)`/`(0,1)` both play exactly once, visibly).
|
||||
|
||||
@@ -246,7 +246,7 @@ fn stationary_cast_hold_stows_an_engaged_casters_weapon() {
|
||||
|
||||
/// The committed-move route: the hold loops masked on the torso over the gait, and its stow must
|
||||
/// HOLD between plays — the reconcile is edge-triggered like the client's (`0x5fdf80` runs only
|
||||
/// inside `PlayAnimation`, wow-re `sheath-policy.md`), so the base track's flags-less Run never
|
||||
/// inside `PlayAnimation`), so the base track's flags-less Run never
|
||||
/// re-draws mid-hold on the frames where nothing plays. This was the caster staff bug's shape:
|
||||
/// the per-frame base-track re-assert yanked the weapon back out one frame after the retake.
|
||||
#[test]
|
||||
@@ -380,7 +380,7 @@ fn spawn_fidgeter(app: &mut App) -> (Entity, Vec<AnimationNodeIndex>) {
|
||||
(unit, nodes)
|
||||
}
|
||||
|
||||
/// The emergent idle fidget (decision 0123 — wow-re `loop-replay-fidget.md` §5b): a RELAXED base
|
||||
/// The emergent idle fidget (decision 0123): a RELAXED base
|
||||
/// arm rolls its variation (the client's `variationIdx = −1`), an engaged one is forced to the
|
||||
/// deterministic head, and the idle re-face turn-shuffle ([`crate::net::FacingStep`]) drives the
|
||||
/// Shuffle↔Stand churn whose every return to Stand re-rolls.
|
||||
@@ -644,8 +644,8 @@ fn spell_flinch_picks_the_wound_by_engagement() {
|
||||
assert_eq!(node(idle), Some(2), "unengaged: StandWound(8)");
|
||||
}
|
||||
|
||||
/// The wound trigger's fourth entry gate (`0x60eaac`–`0x60eac8`, wow-re
|
||||
/// `charproc-rate-override-wound-gate.md`; decision 2063): a CharProc-11 rate-override node on
|
||||
/// The wound trigger's fourth entry gate (`0x60eaac`–`0x60eac8`; decision 2063): a CharProc-11
|
||||
/// rate-override node on
|
||||
/// the unit — what a freeze aura (Ice Block, Freezing Trap, petrify, web wrap) leaves attached —
|
||||
/// refuses every flinch for its life. The gate is the node's *presence*: kit 3071's rate of 1.0
|
||||
/// (no freeze at all) closes it exactly like the family's 0.0. A unit without the node flinches.
|
||||
@@ -801,7 +801,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
|
||||
}
|
||||
|
||||
/// A same-frame swing/kit-anim collision runs the client's COMBAT FAST-PATH (decision 0406,
|
||||
/// wow-re `combat-anim-fastpath.md`): the requests replay in [`PlaySeq`] wire order, the FIRST
|
||||
/// `0x5fe43c`–`0x5fe48b`): the requests replay in [`PlaySeq`] wire order, the FIRST
|
||||
/// arms, and the second — combat over combat — does NOT overwrite it: the armed clip doubles
|
||||
/// to 2× and the second parks in the deferred cache. Both wire orders keep the first arrival on
|
||||
/// the body. The director's ref ground truth this pins: the Eviscerate spin survives the
|
||||
@@ -1058,8 +1058,8 @@ fn a_movement_flag_change_cuts_a_full_body_oneshot_immediately() {
|
||||
|
||||
/// A stationary caster mouselook-turning: the chase-step TURN flag flickers at mouse-event
|
||||
/// cadence (set on delta frames, clear on quiet ones — `drive_body_heading`'s fold), but the
|
||||
/// client's cast pin tests `[9e8] & 0x20000f` — translation + swim, NEVER the turn bits (wow-re
|
||||
/// `spell-visual-apply.md` §2.1, `move_flags::CAST_PIN_MOVE`) — so the full-body hold stays
|
||||
/// client's cast pin tests `[9e8] & 0x20000f` — translation + swim, NEVER the turn bits
|
||||
/// (`0x5fde80`, `move_flags::CAST_PIN_MOVE`) — so the full-body hold stays
|
||||
/// pinned through the flap. Routing this through the one-shot mask (`0x20003f`) instead churned
|
||||
/// the gait hold↔Shuffle on every mouse-delta frame — the frostbolt right-drag jitter
|
||||
/// (decision 0491).
|
||||
@@ -1415,7 +1415,7 @@ fn the_creep_vis_flag_prowls_the_body() {
|
||||
assert_eq!(gait(&app), Some(5), "broken stealth runs again");
|
||||
}
|
||||
|
||||
/// The looping-variation ADVANCE (decision 0516 — wow-re `loop-replay-fidget.md` §7/§7d, the
|
||||
/// The looping-variation ADVANCE (decision 0516 — the
|
||||
/// watchdog `0x719370`): a relaxed looping base arm installs a replay window (here `(1,1)` → one
|
||||
/// pass exactly); each completed window re-arms the id through the weighted, MEMORYLESS variation
|
||||
/// walk. Over a dozen windows both authored Stand variations must take the main slot — the
|
||||
@@ -1574,9 +1574,9 @@ fn a_jump_out_of_a_micro_detachment_still_enters_the_jump_bracket() {
|
||||
}
|
||||
|
||||
/// The control on the launch rule: a step-off fall must stay a step-off fall for its whole arc.
|
||||
/// The gait freeze is the §5-verified behaviour (decision 0868) and the new edge must not reach
|
||||
/// into it — only a rise *past* the threshold is a launch, and a fall only ever accelerates
|
||||
/// downward.
|
||||
/// The gait freeze (`0x5fd8e8`) is the reference's behaviour (decision 0868) and the new edge
|
||||
/// must not reach into it — only a rise *past* the threshold is a launch, and a fall only ever
|
||||
/// accelerates downward.
|
||||
#[test]
|
||||
fn a_deepening_step_off_fall_never_becomes_a_jump() {
|
||||
let mut app = app();
|
||||
@@ -1691,7 +1691,7 @@ fn landing_mid_cast_plays_the_land_pick() {
|
||||
|
||||
/// The FALLINGFAR latch mid-one-shot is an edge (`Jump → Fall`): the client plays Fall(40)
|
||||
/// ONCE, on the substep it latches (`0x61a820` — the 0864-era per-tick re-assert was
|
||||
/// §5-refuted, decision 0868), replacing the clip. A fresh cast armed AFTER the latch then
|
||||
/// refuted, decision 0868), replacing the clip. A fresh cast armed AFTER the latch then
|
||||
/// holds bone 0 like any other airborne one-shot, until the landing pick cuts it.
|
||||
#[test]
|
||||
fn a_cast_over_the_fall_loop_holds_until_landing() {
|
||||
@@ -2061,8 +2061,8 @@ fn the_step_off_arc_freezes_the_gait_against_live_pins() {
|
||||
app.update(); // settle: Stand
|
||||
let gait = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().gait;
|
||||
assert_eq!(gait(&app), Some(0));
|
||||
// Step off a ledge (downward launch: no jump arc, no Special; vz ≠ 0 — the §5-verified
|
||||
// freeze gate `FALLING && (FALLINGFAR || vz ≠ 0)`, decision 0868) …
|
||||
// Step off a ledge (downward launch: no jump arc, no Special; vz ≠ 0 — the freeze gate
|
||||
// `0x5fd8e8`, `FALLING && (FALLINGFAR || vz ≠ 0)`, decision 0868) …
|
||||
app.world_mut().entity_mut(unit).insert(MovementState {
|
||||
flags: move_flags::FALLING,
|
||||
vertical_speed: -3.0,
|
||||
@@ -2149,7 +2149,7 @@ fn a_midair_deferred_park_survives_the_level_and_dies_at_the_landing_play() {
|
||||
}
|
||||
|
||||
/// The deferred cache's consuming read sits DOWNSTREAM of the airborne-freeze (`0x5fd392`
|
||||
/// inside the `0x5fd360` recompute arm; §5-verified, decision 0868): a park made mid-arc is
|
||||
/// inside the `0x5fd360` recompute arm; decision 0868): a park made mid-arc is
|
||||
/// never consumed mid-air, even with the body free — it waits, and the landing edge's play
|
||||
/// clears it.
|
||||
#[test]
|
||||
@@ -2194,8 +2194,8 @@ fn a_midair_park_is_not_consumed_before_landing() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The ranged→melee handoff at every landed swing** (`0x625829`, wow-re `sheath-policy.md` §1's
|
||||
/// `0x6255b0` row) — the director's report: a bow drawn by a shot that never fired, then a melee
|
||||
/// **The ranged→melee handoff at every landed swing** (`0x625829` in
|
||||
/// `0x6255b0`) — the director's report: a bow drawn by a shot that never fired, then a melee
|
||||
/// attack, and the swings keep coming out of the bow. The reconcile provably cannot fix it, and
|
||||
/// this pins both halves: the CONTROL (a sword swing while ranged-drawn leaves the stance at 2 —
|
||||
/// the client's melee force is gated `CUR != 2`, `0x5fe0f9`/`0x5fe13b`, so the ranged stance is
|
||||
@@ -2272,7 +2272,7 @@ fn a_landed_swing_snaps_its_attacker_out_of_the_ranged_stance() {
|
||||
"the reconcile alone never leaves the ranged stance — this is the bug's shape"
|
||||
);
|
||||
|
||||
// The packet arm's snap (what `net::apply::combat::attacker_state` now writes beside the
|
||||
// The packet arm's snap (what `creature_anim::net::attacker_state` now writes beside the
|
||||
// swing): the sword comes out on the first landed blow.
|
||||
app.world_mut().write_message(SheathRequest {
|
||||
entity: unit,
|
||||
@@ -2637,7 +2637,7 @@ fn a_scaled_creatures_walk_cycles_slower_than_an_unscaled_ones() {
|
||||
|
||||
/// The mounted half of the same law: the client's divisor reads the MOUNT model
|
||||
/// (`[unit+0xdc] ?: [unit+0xd8]`), whose rendered scale is the rider's `OBJECT_FIELD_SCALE_X`
|
||||
/// times the mount's own `CreatureDisplayInfo` column (wow-re `0x613ef0`). Our mount child carries
|
||||
/// times the mount's own `CreatureDisplayInfo` column (`0x613ef0`). Our mount child carries
|
||||
/// only that column on its transform, so the driver must compose the host's in — a 1.5× sabre
|
||||
/// under a 2.0× rider divides by 3.0, not 1.5.
|
||||
#[test]
|
||||
@@ -3075,11 +3075,11 @@ fn the_weapon_visual_hold_alone_never_puts_a_shooter_in_the_drawn_idle() {
|
||||
/// The mid-volley half of the same report ("when it's on and I'm running it keeps repeating the
|
||||
/// aim animation weirdly") — **re-derived, and inverted, by decision 1544.**
|
||||
///
|
||||
/// 0994 read `shooter-stop-law.md` §J4 as: the completion dispatcher `0x5fc3f0` is never reached
|
||||
/// for a bow id, so a finished AttackBow recomputes nothing and clamps on its tail. wow-re's §5
|
||||
/// refuted that absence proof — the dispatcher has a SECOND, deferred fire site (`0x719370`
|
||||
/// 0994 read the reference as: the completion dispatcher `0x5fc3f0` is never reached for a bow
|
||||
/// id, so a finished AttackBow recomputes nothing and clamps on its tail. That absence proof is
|
||||
/// wrong — the dispatcher has a SECOND, deferred fire site (`0x719370`
|
||||
/// enqueues the callback as a plain argument with mode 0; `0x7074b0` invokes it later as
|
||||
/// `call [esi+4]`, which an instruction-encoding census cannot see) — and decoded its jump table:
|
||||
/// `call [esi+4]`, which an instruction-encoding census cannot see); its jump table:
|
||||
/// 46/49/107 land on slot 22, a bare `RecomputeBaseAnim(-1)`, and a finished Load lands on slot
|
||||
/// 11/12/15, which arms the Hold **unconditionally**.
|
||||
///
|
||||
@@ -3732,7 +3732,7 @@ fn a_disarmed_attacker_swings_and_stands_unarmed() {
|
||||
|
||||
/// The other rung of the ladder: with **no weapon in the main hand** the disarm falls to the off
|
||||
/// hand, and that — not the dual-wield case — is what reaches AttackUnarmedOff(117) (decision
|
||||
/// 1863; wow-re `disarm-weapon-gate-law.md` §7's table).
|
||||
/// 1863; the swing selector `0x6246a0`).
|
||||
#[test]
|
||||
fn an_off_hand_only_fighter_is_the_case_that_punches_off_hand() {
|
||||
let mut app = app();
|
||||
@@ -3970,9 +3970,8 @@ fn a_no_wound_creature_takes_no_flinch() {
|
||||
}
|
||||
|
||||
/// The flag's **second** consumer: `DO_NOT_PLAY_WOUND_ANIM` takes the victim's **parry** with its
|
||||
/// flinch (`0x60ec1f` inside the parry pick `0x60ec00`, wow-re
|
||||
/// `wound-parry-gate-and-injury-vocal.md` Q1/Q6 — the bit has exactly two callers and this is the
|
||||
/// other one). The `$CPP` ladder enters `0x60ec00` only on victimState 3, so DODGE and BLOCK
|
||||
/// flinch (`0x60ec1f` inside the parry pick `0x60ec00` — the bit has exactly two callers and this
|
||||
/// is the other one). The `$CPP` ladder enters `0x60ec00` only on victimState 3, so DODGE and BLOCK
|
||||
/// reach `PlayAnimation` directly and are **not** gated: a flagged creature still dodges, it just
|
||||
/// never parries. Both halves are asserted here — the gate without its control is the bug this
|
||||
/// whole record is about.
|
||||
@@ -4074,9 +4073,8 @@ fn the_no_wound_flag_takes_the_parry_but_not_the_dodge() {
|
||||
/// `0x602c60` → `0x5fd9e0` → `0x5fd8b0` → `0x5fd100`, and no locomotion id is in the combat set
|
||||
/// that takes its no-latch fast path — so a unit that simply starts running consumes the pending
|
||||
/// arm. That is what starts Charge's trail: kit 44's anim id is `-1`, `0x60f366 jl` skips the play
|
||||
/// block entirely, and the arm waits for the charge's own run (wow-re
|
||||
/// `charproc8-trail-draw-state.md` §11.5/§11.6). A one-shot-only edge would leave 38 Charge spells
|
||||
/// with no trail at all.
|
||||
/// block entirely, and the arm waits for the charge's own run. A one-shot-only edge would leave 38
|
||||
/// Charge spells with no trail at all.
|
||||
///
|
||||
/// The other half matters as much: there is **no per-frame animation recompute** in the reference
|
||||
/// (`0x5fd8b0` has one caller and `0x5fd9e0`'s 38 sites are all event-driven), so a unit standing
|
||||
@@ -4127,8 +4125,7 @@ fn a_gait_change_raises_the_anim_edge_and_a_steady_frame_does_not() {
|
||||
assert!(edge(&app), "and so does a one-shot");
|
||||
}
|
||||
|
||||
/// **The combat fast path must NOT raise the anim edge** (decision 2076, wow-re
|
||||
/// `format7-lighting-term.md`'s second question).
|
||||
/// **The combat fast path must NOT raise the anim edge** (decision 2076).
|
||||
///
|
||||
/// `0x5fe43c` returns at `0x5fe48b` — *before* `0x5fe48e`, the weapon-trail latch's only read —
|
||||
/// when the unit is already playing a combat animation and requests another one. So a pending
|
||||
@@ -4192,7 +4189,7 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The **base-animation lock** (decision 2096, VERIFIED wow-re `base-anim-lock-knockdown.md`) —
|
||||
/// The **base-animation lock** (decision 2096) —
|
||||
/// the reason a Lashed player visibly falls over, and the correction to 2085's reading.
|
||||
///
|
||||
/// A stun's root recomputes the base unconditionally and the selector resolves `Stand(0)`; what
|
||||
|
||||
@@ -65,7 +65,8 @@ pub(super) fn wound_upkeep(entity: Entity, drv: &mut AnimDriver, player: &mut An
|
||||
/// the slot). So this frame's full-body plays (bone 0: a swing on the base, a gait/mode
|
||||
/// change) evict a FULL-BODY wound, and masked-slot plays (the key-bone: a masked swing,
|
||||
/// the cast-hold retake) evict a MASKED wound — while the *other* bone's plays leave the
|
||||
/// wound decaying (§3's inherited-swing case: a full-body swing under a masked wound).
|
||||
/// wound decaying (the kernel `0x714260`'s inherited-swing case: a full-body swing under a
|
||||
/// masked wound).
|
||||
/// This is exactly why the real client's flinch never smothers the next attack: the swing
|
||||
/// reclaims the slot the instant it starts. Mode/gait changes proxy the mode machine's
|
||||
/// plays — a change with no play (Land's re-pick) merely evicts one frame before the play
|
||||
@@ -99,12 +100,12 @@ pub(super) fn wound_evict(
|
||||
}
|
||||
}
|
||||
|
||||
/// The victim wound flinch (decision 0111 — the §5 verdict, rebuilt from bytes after
|
||||
/// The victim wound flinch (decision 0111 — rebuilt from bytes after
|
||||
/// the first routing was director-falsified): a landed hit lays the wound clip into this
|
||||
/// unit's **secondary slot** (`0x60ea70` → op4 `linkFlag=0`) — a decaying 0.75-amplitude
|
||||
/// blend overlay over whatever plays. It never touches the base track or the one-shot
|
||||
/// slot: the victim's own in-flight swing keeps running underneath (there is NO mid-swing
|
||||
/// gate — both §5 trigger agents refuted that hypothesis), and the upkeep above blends it
|
||||
/// gate), and the upkeep above blends it
|
||||
/// out and self-releases — until a same-bone re-arm evicts it (the block above; a wound
|
||||
/// triggered here is this frame's *last* write, matching the client's packet order).
|
||||
/// The trigger's own entry gates live in the caller ([`super::drive_animations`]): the victim's
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user